Compare commits

...

306 Commits

Author SHA1 Message Date
Mauricio Siu
fb6b06f064 chore: add push trigger for version sync on tag creation 2026-04-24 22:46:18 -06:00
Mauricio Siu
09824facf8 refactor: improve Badge component formatting in requests table 2026-04-24 22:34:48 -06:00
Mauricio Siu
bd46eaec5c Merge pull request #4303 from Dokploy/fix/requests-status-fallback-downstream
fix: fallback to DownstreamStatus when OriginStatus is 0 in requests table
2026-04-24 22:33:52 -06:00
Mauricio Siu
e9fdc19b96 fix: fallback to DownstreamStatus when OriginStatus is 0 in requests table
Closes #4250
2026-04-24 22:33:24 -06:00
Mauricio Siu
3e81cdac4d Merge pull request #4255 from manalkaff/fix/requests-filter-by-hostname
fix: filter requests by hostname instead of path
2026-04-24 22:01:35 -06:00
Mauricio Siu
e72c51444c Merge pull request #4281 from sajdakabir/fix/4276-sanitize-webhook-error-responses
fix: stop leaking Drizzle SQL queries in webhook error responses (#4276)
2026-04-24 21:59:50 -06:00
Mauricio Siu
940d18ad25 Merge pull request #4302 from Dokploy/fix/send-email-cloud-version
feat: implement invitation email functionality for organization creation
2026-04-24 21:51:53 -06:00
autofix-ci[bot]
c41b69c925 [autofix.ci] apply automated fixes 2026-04-25 03:40:50 +00:00
Mauricio Siu
b610f7aeff feat: implement invitation email functionality for organization creation
- Added `sendInvitationEmail` function to send invitation emails when a new organization is created in the cloud environment.
- Updated email template to enhance the invitation message and included a direct link for users to accept the invitation.
- Refactored email sending logic in the user router to utilize the new invitation email rendering function.
- Improved organization invitation email design for better user experience.
2026-04-24 21:40:08 -06:00
Mauricio Siu
cdd77a04dc Merge pull request #4129 from NomisCZ/fix/ssh2-isdate-nodejs23
fix: drop .zip deployment - isDate is not a function
2026-04-24 12:58:03 -06:00
Mauricio Siu
05f22edfe5 chore: bump version to v0.29.2 in package.json 2026-04-24 12:53:03 -06:00
Mauricio Siu
29480cde90 Merge pull request #4298 from Dokploy/fix/GHSA-f8wj-5c4w-frhg-cross-org-idor
Fix/ghsa f8wj 5c4w frhg cross org idor
2026-04-24 12:49:24 -06:00
Mauricio Siu
232ccc9139 feat: add organization-level authorization checks to WebSocket servers
- Implemented checks in the WebSocket server setups for Docker container logs, terminal, and deployment logs to ensure users can only access resources associated with their active organization.
- Enhanced security by closing WebSocket connections if the organization ID does not match the session's active organization ID.
2026-04-24 12:47:51 -06:00
Mauricio Siu
018e2b153e fix: add cross-org ownership checks to cluster, deployment, backup, and WebSocket endpoints
Prevents owner/admin users of one organization from accessing servers,
destinations, and Docker Swarm join tokens belonging to other organizations
by validating organizationId on all endpoints that accept serverId or
destinationId as direct input.

- cluster: validate serverId org on getNodes, addWorker, addManager, removeWorker
- deployment: validate serverId org on allByServer
- backup: validate destinationId + serverId org on listBackupFiles
- volume-backups: validate destinationId + serverId org on restoreVolumeBackupWithLogs
- wss: validate server org on docker-container-logs, docker-container-terminal,
  listen-deployment, and terminal WebSocket handlers
- auth: fix TypeScript type for API key metadata parsing
2026-04-24 12:44:42 -06:00
sajdakabir
f8c6c8f7cc fix: stop leaking Drizzle SQL queries in webhook error responses (#4276) 2026-04-22 13:06:22 +05:30
Mauricio Siu
d7af82731c Merge pull request #4279 from Dokploy/fix/GHSA-7wmr-57mg-h5q6-schedule-authz
fix(schedule): add authz checks for server and host-level schedules
2026-04-21 21:38:26 -06:00
Mauricio Siu
c3fa638a56 feat: enhance schedule management with permission checks and cloud restrictions
- Added comprehensive permission checks for creating, updating, and deleting schedules based on user roles (owner/admin) and schedule types (server/dokploy-server).
- Implemented restrictions for cloud users to prevent managing host-level schedules and changing schedule types.
- Improved access control for server-level schedules to ensure users can only manage schedules associated with their organization.
2026-04-21 21:36:44 -06:00
Mauricio Siu
98a586478e chore: bump version to v0.29.1 in package.json 2026-04-19 12:07:02 -06:00
Mauricio Siu
13248c8d8a Merge pull request #4257 from colocated/fix/4256-preview-deployment-too-many-args
fix: preview deployments broken on v0.29.0 — postgres 100-arg limit
2026-04-19 12:06:17 -06:00
Jack
54417ca8e7 fix: limit application columns in findPreviewDeploymentById to avoid postgres 100-arg limit
Closes #4256
2026-04-19 11:14:47 +01:00
manalkaff
598fae0e92 fix: filter requests by hostname instead of path
The search filter on the Requests tab was incorrectly filtering by
RequestPath instead of RequestHost, causing "filter by name" to match
URL paths rather than hostnames. Updated the placeholder text to
reflect the correct field being searched.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 09:52:36 +11:00
Tam Nguyen
f83ab2923d stlye: format and lint 2026-03-30 09:34:27 +11:00
Tam Nguyen
9a1bee5287 fix: more grammar and spelling mistakes 2026-03-30 09:34:27 +11:00
autofix-ci[bot]
6d17f62942 [autofix.ci] apply automated fixes 2026-03-29 22:02:53 +00:00
Tam Nguyen
815b8136fa fix: further typos 2026-03-30 09:01:50 +11:00
impcyber
8ee374dc6b Update gitea.ts
https://github.com/Dokploy/dokploy/issues/4066
2026-03-25 00:16:44 +03:00
vincent-tarrit
c42a16d658 Merge branch 'Dokploy:canary' into 4053-fix-slack-notifications-content 2026-03-24 07:10:24 +01:00
vincent-tarrit
b222409129 lint: fix linter 2026-03-24 07:09:35 +01:00
vincent-tarrit
a322ac374c fix: actions in slack notification 2026-03-23 18:44:14 +01:00
Leonardo Martins
92975a6865 feat: add RHEL flavors to server setup script 2026-03-19 10:32:50 -03:00
Diego Fabricio
4ef8c94340 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-18 21:21:47 -05:00
Diego Fabricio
ff369c9d3a style(dashboard): remove max-width constraint from deployments card
- Deleted max-w-8xl class to allow card width to adapt freely
2026-03-18 21:09:08 -05:00
autofix-ci[bot]
2a2acbfe9a [autofix.ci] apply automated fixes 2026-03-09 06:17:05 +00:00
lasseveenliese
fc8a5153f1 feat: show only used disk space in monitoring graph 2026-03-08 23:46:06 +01:00
Immanuel Daviel A. Garcia
2da45d3ca9 fix: Broken install instructions
This fixes the issue where the installation instructions are instructing to have the user run the install script under the wrong shell.
2026-03-08 02:00:03 -06:00
naturedamends
bf9d2615c2 Better to not have a button 2026-03-05 22:06:51 +00:00
naturedamends
40d07357bc Update save-gitea-provider-compose.tsx 2026-03-05 20:48:37 +00:00
naturedamends
1e5e361094 Update save-gitea-provider-compose.tsx 2026-03-05 20:47:14 +00:00
naturedamends
abc7014b61 Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 20:33:46 +00:00
naturedamends
a8648607b8 Update save-gitea-provider-compose.tsx 2026-03-05 20:33:09 +00:00
naturedamends
453a7b12b6 Replace tooltip trigger with button for help icon
This is only in one provider but can be extened to other providers
2026-03-05 20:24:30 +00:00
Jaime Herrero
fadc7fede5 fix: set FailureAction=rollback for swarm services default UpdateConfig
Docker Swarm's default FailureAction is "pause". When a task fails or is
terminated early during a rolling update, Swarm pauses the update and
stops ALL reconciliation — orphan containers persist indefinitely, even
when healthy. This is the root cause of orphan container issues reported
in production (services showing Replicas: N/1 with multiple healthy
containers that never get cleaned up).

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

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

Relates to #1669, #2223, #2911, #2150
2026-02-28 18:20:12 -05:00
Vibe Code
d7886fb7c9 fix: resolve OpenAPI 500 error caused by BigInt serialization in stopGracePeriodSwarm
Change Drizzle column mode from "bigint" to "number" for stopGracePeriodSwarm
across all 6 service schemas. This fixes JSON.stringify failing silently in the
@dokploy/trpc-openapi adapter, which unlike the tRPC endpoint does not use
superjson and cannot serialize BigInt values.

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

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

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

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

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

---

## New Files

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

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

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

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

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

## Modified Files

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

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

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

## Features

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

## Edge Cases Handled

1. Swarm not initialized (tRPC error or undefined data)
2. Docker command failures (stderr / non-zero exit)
3. Swarm active but no services deployed
4. Services exist but no running containers
5. Containers with Docker errors (shown in tooltip + error alert)
6. Nodes down or drained (cross-referenced from node list)
7. Multi-node setups (metrics only from manager node)
8. Services scaled to 0 replicas (separated from running containers)
9. Empty stdout from docker commands (no JSON.parse crash)
2026-02-07 19:19:07 +00:00
Statsly-org
0a4becb614 refactor: move icon tab content into ShowIconSettings component 2026-02-06 22:41:27 +01:00
Statsly-org
e85adaedbd chore: remove Under Development notice from icon tab 2026-02-06 18:16:05 +01:00
Statsly-org
32657499ab feat: add icon upload functionality for applications 2026-02-06 18:00:49 +01:00
Othman Haba (ง'̀-'́)ง
67899c762c fix(postgres): set default StopGracePeriod to 30 seconds if not provided 2026-02-06 03:37:34 +02:00
stripsior
ab69d782c7 fix: remove default from mariadb, make mongo 8 default 2026-01-03 17:53:10 +01:00
stripsior
405fc69df4 chore(databases): update mongodb version, to patch latest cve 2026-01-03 15:30:45 +01:00
David Eiber
ce5ad35981 feat: add middlewares to domains 2026-01-01 16:18:40 +01:00
autofix-ci[bot]
c66902fb96 [autofix.ci] apply automated fixes 2025-12-26 13:03:51 +00:00
Bima42
70776ba8ca feat: be able to edit certificate 2025-12-26 14:00:58 +01:00
Bima42
2df1b42540 fix: update regex to allow number at the end of app name 2025-12-26 13:26:23 +01:00
quochuydev
48902c488f feat: add grid/table view toggle for domains page 2025-12-17 18:48:19 +07:00
faytranevozter
e575e50979 feat(certificates): enhance certificate handling with common name extraction and chain details 2025-12-16 20:14:31 +07:00
autofix-ci[bot]
efedec70d6 [autofix.ci] apply automated fixes 2025-12-15 21:02:33 +00:00
mkarpats
8d11fb4ee8 chore: update baseDomain used in host-rule-format tests 2025-12-15 14:39:48 +02:00
mkarpats
b7f7027280 Merge branch 'refs/heads/canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0131_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-12-12 18:26:29 +02:00
mkarpats
5d078f1d9f Merge remote-tracking branch 'refs/remotes/origin/canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0130_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-12-09 12:07:46 +02:00
Mauricio Siu
ac27aa1bba feat(migration): add customEntrypoint column to domain table
- Introduced a new SQL migration script `0130_abandoned_dagger.sql` to add the `customEntrypoint` column to the `domain` table.
- Updated the journal entry in `_journal.json` to reflect this new migration.
- Created a snapshot file `0130_snapshot.json` to capture the current state of the database schema after this migration.
2025-12-07 20:46:50 -06:00
Mauricio Siu
6a79ce8ff1 Merge branch 'canary' into feature/custom-entrypoint 2025-12-07 20:46:32 -06:00
Mauricio Siu
bf226f1af1 chore: remove unused SQL script and journal entry for sleepy aqueduct migration
- Deleted the SQL script `0122_sleepy_aqueduct.sql` which added a `customEntrypoint` column to the `domain` table.
- Removed the corresponding journal entry from `_journal.json` to clean up migration history.
- Deleted the snapshot file `0122_snapshot.json` as it is no longer needed.
2025-12-07 20:45:45 -06:00
mkarpats
6b117551ae fix: add middlewares (stipPath and/or internalPath) when using custom entry point 2025-11-22 18:56:39 +02:00
mkarpats
8c1153370c Merge branch 'refs/heads/canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0120_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-11-22 18:54:36 +02:00
autofix-ci[bot]
09dd7cc938 [autofix.ci] apply automated fixes 2025-11-09 20:02:23 +00:00
Bima42
eae83674b0 chore: regenerate migration after merge 2025-11-09 21:02:01 +01:00
Bima42
d1ebc133aa chore: regenerate migrations 2025-11-09 20:52:48 +01:00
Bima42
d29fe437b9 fix: typo in modal, unify with bookmark 2025-11-09 20:51:23 +01:00
Bima42
27ad851d45 feat(ui): update template modal to use bookmarks 2025-11-09 20:51:23 +01:00
Bima42
42f8773c05 feat(api): add routes for template bookmarks 2025-11-09 20:51:23 +01:00
Bima42
5eef844e5f feat(db): add user bookmark table and migrations 2025-11-09 20:51:20 +01:00
mkarpats
21fa21e9c0 Merge branch 'canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0119_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-11-05 21:14:11 +02:00
autofix-ci[bot]
14dafa9a8a [autofix.ci] apply automated fixes 2025-11-05 06:27:27 +00:00
HarikrishnanD
c5eb31ab90 feat: add web UI file upload to Docker containers 2025-11-05 11:55:40 +05:30
autofix-ci[bot]
d6704dbd27 [autofix.ci] apply automated fixes 2025-11-04 10:38:15 +00:00
HarikrishnanD
dcdbed047b fix: change backup file naming to Windows-compatible format 2025-11-04 16:06:01 +05:30
Léo FILMON
c362b2c558 feat: add password manager compatible OTP input component
- Redesigned InputOTP component with modern visual boxes
- Added native password manager support (Dashlane, 1Password, etc.)
- Implemented automatic cursor movement on input/delete
- Removed legacy slot-based components (InputOTPGroup, InputOTPSlot, InputOTPSeparator)
- Updated login page and 2FA setup to use new component
- Enhanced UX with hover effects, focus states, and filled state styling

The new InputOTP component uses a hidden native input with autoComplete='one-time-code'
for password manager compatibility while displaying modern rounded boxes with smooth
animations and visual feedback.
2025-11-04 01:43:09 +00:00
WalidDevIO
91a385c302 feat[notifications]: Add dokployBackup notification type support
This commit adds support for the dokployBackup notification type across all relevant services and schemas.
2025-10-27 22:43:45 +01:00
mkarpats
9627af9cda Merge branch 'canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0117_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-10-26 14:31:05 +02:00
autofix-ci[bot]
90bd276ad4 [autofix.ci] apply automated fixes 2025-10-25 07:11:54 +00:00
mkarpats
84d311802f feat: add support for custom entry point 2025-10-19 21:22:06 +03:00
286 changed files with 95116 additions and 1790 deletions

View File

@@ -68,3 +68,45 @@ jobs:
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"

83
.github/workflows/sync-version.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Sync version to MCP and CLI repos
on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
sync-version:
name: Sync version to external repos
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
- name: Get version
id: get_version
run: |
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
# Regenerate tools from latest OpenAPI spec
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
# Copy latest openapi spec and regenerate commands
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
if [ -f package.json ]; then
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
fi
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"

View File

@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash
curl -sSL https://dokploy.com/install.sh | sh
curl -sSL https://dokploy.com/install.sh | bash
```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).

View File

@@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
customEntrypoint: null,
middlewares: null,
};
describe("Host rule format validation", () => {

View File

@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
@@ -21,6 +22,7 @@ describe("createDomainLabels", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
};
it("should create basic labels for web entrypoint", async () => {
@@ -171,12 +173,12 @@ describe("createDomainLabels", () => {
"websecure",
);
// Web entrypoint should have both middlewares with redirect first
// Web entrypoint with HTTPS should only have redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Websecure should only have the addprefix middleware
// Websecure should have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
@@ -208,9 +210,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Should have middlewares in correct order: redirect, stripprefix, addprefix
// Web router with HTTPS should only have redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
});
@@ -240,4 +242,259 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
});

View File

@@ -292,7 +292,7 @@ networks:
dokploy-network:
`;
test("It shoudn't add suffix to dokploy-network", () => {
test("It shouldn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -195,7 +195,7 @@ services:
- dokploy-network
`;
test("It shoudn't add suffix to dokploy-network in services", () => {
test("It shouldn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -241,10 +241,10 @@ services:
dokploy-network:
aliases:
- apid
`;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -415,5 +415,24 @@ describe("Docker Image Name and Tag Extraction", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
});
});

View File

@@ -120,6 +120,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,

View File

@@ -1,4 +1,4 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -72,7 +72,7 @@ PASSWORD=secret123
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
@@ -95,7 +95,7 @@ NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
@@ -170,7 +170,7 @@ SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceWithEmpty,
projectEnv,
"",

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from "vitest";
import {
enterpriseOnlyResources,
statements,
} from "@dokploy/server/lib/access-control";
import { describe, expect, it } from "vitest";
const FREE_TIER_RESOURCES = [
"organization",

View File

@@ -57,7 +57,7 @@ const createApplication = (
env: null,
},
replicas: 1,
stopGracePeriodSwarm: 0n,
stopGracePeriodSwarm: 0,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
});
});
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0n });
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0 });
await mechanizeDockerContainer(application);

View File

@@ -95,6 +95,7 @@ const baseApp: ApplicationNested = {
dropBuildPath: null,
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -137,6 +138,7 @@ const baseDomain: Domain = {
https: false,
path: null,
port: null,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
@@ -145,6 +147,7 @@ const baseDomain: Domain = {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
};
const baseRedirect: Redirect = {
@@ -264,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1");
});
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */
test("CertificateType on websecure entrypoint", async () => {
@@ -276,6 +353,130 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {

View File

@@ -110,6 +110,13 @@ const menuItems: MenuItem[] = [
},
];
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props {
id: string;
type:

View File

@@ -40,12 +40,12 @@ interface Props {
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
}
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(),
});
type AddCommand = z.infer<typeof AddRedirectchema>;
type AddCommand = z.infer<typeof AddRedirectSchema>;
export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
@@ -87,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}),
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {

View File

@@ -16,7 +16,7 @@ import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
@@ -68,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const form = useForm<any>({
defaultValues: {
value: null as bigint | null,
value: null as number | null,
},
});
@@ -76,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
value === null || value === undefined ? null : Number(value);
form.reset({
value: normalizedValue,
});
@@ -136,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
e.target.value ? Number(e.target.value) : null,
)
}
/>

View File

@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type AddRedirect = z.infer<typeof AddRedirectchema>;
type AddRedirect = z.infer<typeof AddRedirectSchema>;
// Default presets
const redirectPresets = [
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
regex: "",
replacement: "",
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => {
setIsOpen(open);
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected("");
// form.reset();
};

View File

@@ -1,6 +1,7 @@
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && (
<div className="flex items-center space-x-2">

View File

@@ -1,3 +1,4 @@
import copy from "copy-to-clipboard";
import {
ChevronDown,
ChevronUp,
@@ -11,7 +12,6 @@ import {
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import copy from "copy-to-clipboard";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -0,0 +1,303 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
CheckCircle2,
ExternalLink,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import type { ValidationStates } from "./show-domains";
import { AddDomain } from "./handle-domain";
import { DnsHelperModal } from "./dns-helper-modal";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
| RouterOutputs["domain"]["byComposeId"][0];
interface ColumnsProps {
id: string;
type: "application" | "compose";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
isDeleting: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
}
export const createColumns = ({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting,
serverIp,
canCreateDomain,
canDeleteDomain,
}: ColumnsProps): ColumnDef<Domain>[] => [
...(type === "compose"
? [
{
accessorKey: "serviceName",
header: "Service",
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
const serviceName = row.getValue("serviceName") as string | null;
if (!serviceName) return null;
return (
<Badge variant="outline">
<Server className="size-3 mr-1" />
{serviceName}
</Badge>
);
},
} satisfies ColumnDef<Domain>,
]
: []),
{
accessorKey: "host",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Host
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
);
},
},
{
accessorKey: "path",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Path
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const path = row.getValue("path") as string;
return <div className="font-mono text-sm">{path || "/"}</div>;
},
},
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const port = row.getValue("port") as number;
return <Badge variant="secondary">{port}</Badge>;
},
},
{
accessorKey: "customEntrypoint",
header: "Entrypoint",
cell: ({ row }) => {
const entrypoint = row.getValue("customEntrypoint") as string | null;
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
return <div className="font-mono text-sm">{entrypoint}</div>;
},
},
{
accessorKey: "https",
header: "Protocol",
cell: ({ row }) => {
const https = row.getValue("https") as boolean;
return (
<Badge variant={https ? "outline" : "secondary"}>
{https ? "HTTPS" : "HTTP"}
</Badge>
);
},
},
{
id: "certificate",
header: "Certificate",
cell: ({ row }) => {
const domain = row.original;
const validationState = validationStates[domain.host];
return (
<div className="flex items-center gap-2">
{domain.certificateType && (
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("traefik.me") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() => handleValidateDomain(domain.host)}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message && validationState.cdnProvider
? `${validationState.cdnProvider}`
: "Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
Invalid
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return (
<div className="text-sm text-muted-foreground">
{new Date(createdAt).toLocaleDateString()}
</div>
);
},
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const domain = row.original;
return (
<div className="flex items-center gap-2">
{!domain.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: domain.host,
https: domain.https,
path: domain.path || undefined,
}}
serverIp={serverIp}
/>
)}
{canCreateDomain && (
<AddDomain id={id} type={type} domainId={domain.domainId}>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 h-8 w-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(domain.domainId);
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 h-8 w-8"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
},
},
];

View File

@@ -1,11 +1,12 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -61,11 +62,14 @@ export const domain = z
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
@@ -114,6 +118,14 @@ export const domain = z
message: "Internal path must start with '/'",
});
}
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
});
type Domain = z.infer<typeof domain>;
@@ -196,16 +208,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: undefined,
domainType: type,
middlewares: [],
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
@@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false,
port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
});
}
@@ -234,10 +253,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
domainType: type,
middlewares: [],
});
}
}, [form, data, isPending, domainId]);
@@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
}),
...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
@@ -635,6 +658,55 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}}
/>
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domain
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue("customEntrypoint", undefined);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="https"
@@ -725,6 +797,88 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)}
</>
)}
<FormField
control={form.control}
name="middlewares"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add Traefik middleware references. Middlewares
must be defined in your Traefik configuration.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((name, index) => (
<Badge key={index} variant="secondary">
{name}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newMiddlewares = [...(field.value || [])];
newMiddlewares.splice(index, 1);
form.setValue("middlewares", newMiddlewares);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="e.g., rate-limit@file, auth@file"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="e.g., rate-limit@file, auth@file"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>

View File

@@ -1,8 +1,22 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
CheckCircle2,
ChevronDown,
ExternalLink,
GlobeIcon,
InfoIcon,
LayoutGrid,
LayoutList,
Loader2,
PenBoxIcon,
RefreshCw,
@@ -23,6 +37,21 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
@@ -30,6 +59,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
@@ -74,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (typeof window !== "undefined") {
return (
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
"grid"
);
}
return "grid";
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { data: ip } = api.settings.getIp.useQuery();
const {
@@ -103,6 +146,16 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
} catch {
toast.error("Error deleting domain");
}
};
const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({
...prev,
@@ -140,6 +193,37 @@ export const ShowDomains = ({ id, type }: Props) => {
}
};
const columns = createColumns({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting: isRemoving,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
});
const table = useReactTable({
data: data ?? [],
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -151,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription>
</div>
<div className="flex flex-row gap-4 flex-wrap">
{canCreateDomain && data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
<div className="flex flex-row gap-2 flex-wrap">
{data && data?.length > 0 && (
<>
<Button
variant="outline"
size="icon"
onClick={() => {
const next = viewMode === "grid" ? "table" : "grid";
localStorage.setItem("domains-view-mode", next);
setViewMode(next);
}}
>
{viewMode === "grid" ? (
<LayoutList className="size-4" />
) : (
<LayoutGrid className="size-4" />
)}
</Button>
</AddDomain>
{canCreateDomain && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
)}
</div>
</CardHeader>
@@ -186,6 +289,122 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
)}
</div>
) : viewMode === "table" ? (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by host..."
value={
(table.getColumn("host")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("host")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
{data?.map((item) => {
@@ -341,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider>
)}
{item.middlewares?.map((middleware, index) => (
<TooltipProvider key={`${middleware}-${index}`}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Middleware: {middleware}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Traefik middleware reference</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
compose: () => api.compose.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
compose: () => api.compose.saveEnvironment.useMutation(),
libsql: () => api.libsql.saveEnvironment.useMutation(),
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
mongo: () => api.mongo.saveEnvironment.useMutation(),
mysql: () => api.mysql.saveEnvironment.useMutation(),
postgres: () => api.postgres.saveEnvironment.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
: api.mongo.saveEnvironment.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {
@@ -116,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}

View File

@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}

View File

@@ -55,7 +55,7 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const router = useRouter();
const { mutateAsync, isPending } =

View File

@@ -0,0 +1,277 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
icon?: string | null;
}
const svgToDataUrl = (icon: BundledIcon): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
};
export const ShowIconSettings = ({
applicationId,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
const [iconSearchQuery, setIconSearchQuery] = useState("");
const [iconsToShow, setIconsToShow] = useState(24);
const filteredIcons = useMemo(() => {
if (!iconSearchQuery) return bundledIcons;
const q = iconSearchQuery.toLowerCase();
return bundledIcons.filter(
(i) =>
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
);
}, [iconSearchQuery]);
const displayedIcons = filteredIcons.slice(0, iconsToShow);
const hasMoreIcons = filteredIcons.length > iconsToShow;
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
useEffect(() => {
if (open) {
setIconSearchQuery("");
setIconsToShow(24);
}
}, [open]);
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
reader.readAsDataURL(file);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative group flex items-center justify-center"
>
{icon ? (
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
className="h-8 w-8 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="h-3 w-3 text-white" />
</div>
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
Change Icon
{icon && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveIcon}
className="text-muted-foreground"
>
<X className="size-4 mr-1" />
Remove icon
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search icons (e.g. react, vue, docker)..."
value={iconSearchQuery}
onChange={(e) => setIconSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
{displayedIcons.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No icons found
</div>
) : (
<>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{displayedIcons.map((i) => (
<button
type="button"
key={i.slug}
onClick={() => handleIconSelect(i)}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="size-7 group-hover:scale-110 transition-transform"
fill={`#${i.hex}`}
>
<path d={i.path} />
</svg>
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
{i.title}
</span>
</button>
))}
</div>
{hasMoreIcons && (
<div className="flex justify-center mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setIconsToShow((prev) => prev + 24)}
>
Load More ({filteredIcons.length - iconsToShow} remaining)
</Button>
</div>
)}
</>
)}
</div>
<div className="relative pt-3 border-t">
<p className="text-sm text-muted-foreground text-center mb-3">
or upload a custom icon
</p>
<Dropzone
dropMessage="Drag & drop an icon or click to upload"
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
/>
<div className="mt-2 text-center text-xs text-muted-foreground">
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
const containersLength =
option === "native" ? containers?.length : services?.length;
return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
<SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -1,2 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";
export * from "./show-patches";

View File

@@ -483,7 +483,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the
Choose the volume to backup. If you do not see the
volume here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -518,7 +518,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the volume
Choose the volume to backup. If you do not see the volume
here, you can type the volume name manually
</FormDescription>
<FormMessage />

View File

@@ -0,0 +1,290 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};

View File

@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}

View File

@@ -55,7 +55,7 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const router = useRouter();
const { mutateAsync, isPending } = api.compose.update.useMutation();

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -409,10 +409,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>

View File

@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
const containersLength =
option === "native" ? containers?.length : services?.length;
return (
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
<SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -225,7 +225,7 @@ export const RestoreBackup = ({
resolver: zodResolver(RestoreBackupSchema),
});
const destionationId = form.watch("destinationId");
const destinationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
@@ -240,12 +240,12 @@ export const RestoreBackup = ({
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
destinationId: destinationId,
search: debouncedSearchTerm,
serverId: serverId ?? "",
},
{
enabled: isOpen && !!destionationId,
enabled: isOpen && !!destinationId,
},
);

View File

@@ -1,8 +1,8 @@
"use client";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {

View File

@@ -0,0 +1,189 @@
"use client";
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import type { LogLine } from "./utils";
interface Props {
logs: LogLine[];
context: "build" | "runtime";
}
const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const handleAnalyze = () => {
if (!aiId || logs.length === 0) return;
const logsText = logs
.slice(-MAX_LOG_LINES)
.map((l) => l.message)
.join("\n");
mutate({ aiId, logs: logsText, context });
};
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
reset();
setAiId("");
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9"
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 h-4 w-4" />
AI
</Button>
</PopoverTrigger>
<PopoverContent className="w-[550px] p-0" align="end">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
<span className="text-sm font-medium">Log Analysis</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setOpen(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="p-4 space-y-3">
{!data?.analysis ? (
providers && providers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm text-muted-foreground">
No AI providers configured. Set up a provider to start
analyzing logs.
</p>
<Button size="sm" variant="outline" asChild>
<Link href="/dashboard/settings/ai">
<Settings className="mr-2 h-3.5 w-3.5" />
Configure AI Provider
</Link>
</Button>
</div>
) : (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze{" "}
{logs.length > MAX_LOG_LINES
? `last ${MAX_LOG_LINES}`
: logs.length}{" "}
lines
</>
)}
</Button>
</>
)
) : (
<>
<div className="max-h-[400px] overflow-y-auto">
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
<ReactMarkdown>{data.analysis}</ReactMarkdown>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
reset();
handleAnalyze();
}}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
Re-analyze
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setAiId("");
}}
title="Change provider"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter";
@@ -377,6 +378,7 @@ export const DockerLogsId: React.FC<Props> = ({
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (

View File

@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
>
{" "}
<div className="flex items-start gap-x-2">
{/* Icon to expand the log item maybe implement a colapsible later */}
{/* Icon to expand the log item maybe implement a collapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (

View File

@@ -74,6 +74,18 @@ export function parseLogs(logString: string): LogLine[] {
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
if (statusMatch) {
const statusCode = Number(statusMatch[1]);
if (statusCode >= 500) return LOG_STYLES.error;
if (statusCode >= 400) return LOG_STYLES.warning;
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
return LOG_STYLES.info;
}
const lowerMessage = message.toLowerCase();
if (

View File

@@ -0,0 +1,112 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Mount {
Type: string;
Source: string;
Destination: string;
Mode: string;
RW: boolean;
Propagation: string;
Name?: string;
Driver?: string;
}
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const mounts: Mount[] = data?.Mounts ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Mounts
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Mounts</DialogTitle>
<DialogDescription>
Volume and bind mounts for this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{mounts.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No mounts found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Read/Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mounts.map((mount, index) => (
<TableRow key={index}>
<TableCell>
<Badge variant="outline">{mount.Type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Name || mount.Source}
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Destination}
</TableCell>
<TableCell className="text-xs">
{mount.Mode || "-"}
</TableCell>
<TableCell>
<Badge variant={mount.RW ? "default" : "secondary"}>
{mount.RW ? "RW" : "RO"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,119 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Network {
IPAMConfig: unknown;
Links: unknown;
Aliases: string[] | null;
MacAddress: string;
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
DriverOpts: unknown;
}
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const networks: Record<string, Network> =
data?.NetworkSettings?.Networks ?? {};
const entries = Object.entries(networks);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Networks
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Networks</DialogTitle>
<DialogDescription>
Networks attached to this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No networks found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Gateway</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Aliases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([name, network]) => (
<TableRow key={name}>
<TableCell>
<Badge variant="outline">{name}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{network.IPAddress
? `${network.IPAddress}/${network.IPPrefixLen}`
: "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.Gateway || "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.MacAddress || "-"}
</TableCell>
<TableCell className="text-xs">
{network.Aliases?.join(", ") || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -10,8 +10,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers";
export const columns: ColumnDef<Container>[] = [
@@ -122,12 +125,26 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={container.serverId || ""}
>
Terminal
</DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
<RemoveContainerDialog
containerId={container.containerId}
serverId={container.serverId ?? undefined}

View File

@@ -35,7 +35,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
import { columns } from "./colums";
import { columns } from "./columns";
export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"]
>[0];

View File

@@ -0,0 +1,187 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
uploadFileToContainerSchema,
type UploadFileToContainer,
} from "@/utils/schema";
interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
const [open, setOpen] = useState(false);
const { mutateAsync: uploadFile, isPending: isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || "Failed to upload file to container");
},
});
const form = useForm({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});
const file = form.watch("file");
const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}
const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}
await uploadFile(formData);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be uploaded in the
container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,291 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};

View File

@@ -1,14 +1,19 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mariadbId: string;
}
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data } = api.mariadb.one.useQuery({ mariadbId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mariadb.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -28,20 +33,43 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
/>
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">

View File

@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
};
setConnectionUrl(buildConnectionUrl());

View File

@@ -1,14 +1,19 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mongoId: string;
}
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
const { data } = api.mongo.one.useQuery({ mongoId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mongo.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -25,11 +30,21 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mongoId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.mongo.one.invalidate({ mongoId });
}}
/>
</div>
</div>
@@ -47,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
/>
</div>
</div>

View File

@@ -1,103 +1,103 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["block"];
accumulativeData: DockerStatsJSON["block"];
}
export const DockerBlockChart = ({ acummulativeData }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
};
});
const chartConfig = {
readMb: {
label: "Read (MB)",
color: "hsl(var(--chart-1))",
},
writeMb: {
label: "Write (MB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="readMb"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
name="Read Mb"
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorWrite)"
name="Write Mb"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-readMb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-readMb)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-writeMb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-writeMb)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value, name) => {
const label = name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
}}
/>
}
/>
<Area
type="monotone"
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
readMb: number;
writeMb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Read ${payload[0].payload.readMb} `}</p>
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
</div>
);
}
return null;
};

View File

@@ -1,87 +1,81 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["cpu"];
accumulativeData: DockerStatsJSON["cpu"];
}
export const DockerCpuChart = ({ acummulativeData }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
};
});
const chartConfig = {
usage: {
label: "CPU Usage",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export const DockerCpuChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usage"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usage)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usage)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value}%`, "CPU Usage"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
</div>
);
}
return null;
};

View File

@@ -1,105 +1,82 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["disk"];
accumulativeData: DockerStatsJSON["disk"];
diskTotal: number;
}
export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
freeGb: item.value.diskFree,
};
});
const chartConfig = {
usedGb: {
label: "Used (GB)",
color: "hsl(var(--chart-3))",
},
} satisfies ChartConfig;
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usedGb"
stroke="#6C28D9"
fillOpacity={1}
fill="url(#colorUsed)"
name="Used GB"
/>
<Area
type="monotone"
dataKey="freeGb"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorFree)"
name="Free GB"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usedGb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usedGb)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
domain={[0, diskTotal]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value} GB`}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => {
return [`${value} GB`, "Used"];
}}
/>
}
/>
<Area
type="monotone"
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usedGb: number;
freeGb: number;
totalGb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -0,0 +1,182 @@
import { Loader2, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { Cell, Label, Pie, PieChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
const TYPE_TO_KEY: Record<string, string> = {
Images: "images",
Containers: "containers",
"Local Volumes": "volumes",
"Build Cache": "buildCache",
};
const chartConfig = {
value: {
label: "Size",
},
images: {
label: "Images",
color: "hsl(var(--chart-1))",
},
containers: {
label: "Containers",
color: "hsl(var(--chart-2))",
},
volumes: {
label: "Volumes",
color: "hsl(var(--chart-3))",
},
buildCache: {
label: "Build Cache",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
const formatSize = (bytes: number): string => {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
export const DockerDiskUsageChart = () => {
const { data, isLoading, refetch, isRefetching } =
api.settings.getDockerDiskUsage.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { chartData, totalBytes } = useMemo(() => {
const items =
data
?.filter((item) => item.sizeBytes > 0)
.map((item) => {
const key = TYPE_TO_KEY[item.type] ?? item.type;
return {
name: key,
value: item.sizeBytes,
size: item.size,
active: item.active,
totalCount: item.totalCount,
reclaimable: item.reclaimable,
fill: `var(--color-${key})`,
};
}) ?? [];
return {
chartData: items,
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
};
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[16rem]">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (chartData.length === 0) {
return (
<p className="text-xs text-muted-foreground mt-4">
No Docker disk usage data available.
</p>
);
}
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Total: {formatSize(totalBytes)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<ChartContainer
config={chartConfig}
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => {
const item = chartData.find((d) => d.name === name);
if (!item) return [formatSize(value as number), name];
return [
`${item.size}${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
chartConfig[name as keyof typeof chartConfig]?.label ??
name,
];
}}
/>
}
/>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={60}
outerRadius={85}
strokeWidth={3}
stroke="hsl(var(--background))"
minAngle={15}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 8}
className="fill-foreground text-2xl font-bold"
>
{formatSize(totalBytes)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 14}
className="fill-muted-foreground text-xs"
>
Docker Usage
</tspan>
</text>
);
}
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
</div>
);
};

View File

@@ -1,93 +1,87 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["memory"];
accumulativeData: DockerStatsJSON["memory"];
memoryLimitGB: number;
}
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({
acummulativeData,
accumulativeData,
memoryLimitGB,
}: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usage"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usage)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usage)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
tickFormatter={(value) => `${value} GB`}
domain={[0, +memoryLimitGB.toFixed(2)]}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value} GB`, "Memory"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0] && payload[0].payload) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -1,99 +1,99 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["network"];
accumulativeData: DockerStatsJSON["network"];
}
export const DockerNetworkChart = ({ acummulativeData }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
};
});
const chartConfig = {
inMB: {
label: "In (MB)",
color: "hsl(var(--chart-1))",
},
outMB: {
label: "Out (MB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="inMB"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
name="In MB"
/>
<Area
type="monotone"
dataKey="outMB"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorUv)"
name="Out MB"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
<stop
offset="95%"
stopColor="var(--color-inMB)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-outMB)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-outMB)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
}}
/>
}
/>
<Area
type="monotone"
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
inMB: number;
outMB: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
</div>
);
}
return null;
};

View File

@@ -5,6 +5,7 @@ import { api } from "@/utils/api";
import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart";
@@ -124,7 +125,7 @@ export const ContainerFreeMonitoring = ({
refetchOnWindowFocus: false,
},
);
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
cpu: [],
memory: [],
block: [],
@@ -136,7 +137,7 @@ export const ContainerFreeMonitoring = ({
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
setAccumulativeData({
cpu: [],
memory: [],
block: [],
@@ -155,7 +156,7 @@ export const ContainerFreeMonitoring = ({
network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
});
setAcummulativeData({
setAccumulativeData({
block: data?.block || [],
cpu: data?.cpu || [],
disk: data?.disk || [],
@@ -184,7 +185,7 @@ export const ContainerFreeMonitoring = ({
setCurrentData(data);
const MAX_DATA_POINTS = 300;
setAcummulativeData((prevData) => ({
setAccumulativeData((prevData) => ({
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
@@ -219,16 +220,16 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}
Used: {String(currentData.cpu.value ?? "0%")}
</span>
<Progress
value={Number.parseInt(
currentData.cpu.value.replace("%", ""),
String(currentData.cpu.value ?? "0%").replace("%", ""),
10,
)}
className="w-[100%]"
/>
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
</div>
</CardContent>
</Card>
@@ -252,7 +253,7 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]"
/>
<DockerMemoryChart
acummulativeData={acummulativeData.memory}
accumulativeData={accumulativeData.memory}
memoryLimitGB={
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) /
@@ -277,13 +278,25 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]"
/>
<DockerDiskChart
acummulativeData={acummulativeData.disk}
accumulativeData={accumulativeData.disk}
diskTotal={currentData.disk.value.diskTotal}
/>
</div>
</CardContent>
</Card>
)}
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Docker Disk Usage
</CardTitle>
</CardHeader>
<CardContent>
<DockerDiskUsageChart />
</CardContent>
</Card>
)}
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -294,7 +307,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
</span>
<DockerBlockChart acummulativeData={acummulativeData.block} />
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
</CardContent>
</Card>
@@ -307,7 +320,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
</span>
<DockerNetworkChart acummulativeData={acummulativeData.network} />
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>
</CardContent>
</Card>

View File

@@ -1,14 +1,19 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mysqlId: string;
}
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data } = api.mysql.one.useQuery({ mysqlId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mysql.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -28,20 +33,43 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
/>
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">

View File

@@ -1,14 +1,19 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
postgresId: string;
}
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
const { data } = api.postgres.one.useQuery({ postgresId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.postgres.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -28,11 +33,21 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
value={data?.databasePassword}
disabled
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
postgresId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.postgres.one.invalidate({ postgresId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">

View File

@@ -43,6 +43,7 @@ import {
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddTemplateSchema = z.object({
name: z.string().min(1, {
@@ -53,9 +54,8 @@ const AddTemplateSchema = z.object({
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
description: z.string().optional(),
serverId: z.string().optional(),

View File

@@ -43,6 +43,7 @@ import {
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(),
@@ -54,9 +55,8 @@ const AddComposeSchema = z.object({
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
description: z.string().optional(),
serverId: z.string().optional(),
@@ -78,9 +78,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation();
// Get environment data to extract projectId
// const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)

View File

@@ -52,12 +52,13 @@ import {
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
type DbType = z.infer<typeof mySchema>["type"];
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:8",
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:18",
@@ -82,9 +83,8 @@ const baseDatabaseSchema = z.object({
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
databasePassword: z
.string()

View File

@@ -1,5 +1,6 @@
import {
BookText,
Bookmark,
CheckIcon,
ChevronsUpDown,
Globe,
@@ -82,6 +83,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
// Try to get from props first, then localStorage
if (baseUrl) return baseUrl;
@@ -122,8 +124,45 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
enabled: open,
},
);
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
api.user.getBookmarkedTemplates.useQuery(undefined, {
enabled: open,
});
const utils = api.useUtils();
const { mutateAsync: toggleBookmark } =
api.user.toggleTemplateBookmark.useMutation({
onMutate: async ({ templateId }) => {
await utils.user.getBookmarkedTemplates.cancel();
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
if (old.includes(templateId)) {
return old.filter((id) => id !== templateId);
}
return [...old, templateId];
});
return { previousBookmarks };
},
onError: (err, variables, context) => {
if (context?.previousBookmarks) {
utils.user.getBookmarkedTemplates.setData(
undefined,
context.previousBookmarks,
);
}
toast.error("Failed to update bookmark");
},
onSuccess: (data) => {
toast.success(
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
);
},
});
const [serverId, setServerId] = useState<string | undefined>(undefined);
const { mutateAsync, isPending, error, isError } =
api.compose.deployTemplate.useMutation();
@@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
query === "" ||
template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase());
return matchesTags && matchesQuery;
const matchesBookmarks =
!showBookmarksOnly || bookmarkIds.includes(template.id);
return matchesTags && matchesQuery && matchesBookmarks;
}) || [];
const hasServers = servers && servers.length > 0;
@@ -146,6 +187,14 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const handleToggleBookmark = async (
e: React.MouseEvent,
templateId: string,
) => {
e.stopPropagation();
await toggleBookmark({ templateId });
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="w-full">
@@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</Command>
</PopoverContent>
</Popover>
<Button
variant={showBookmarksOnly ? "default" : "outline"}
size="icon"
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
className="h-9 w-9 flex-shrink-0"
disabled={isLoadingBookmarks}
>
<Bookmark
className={cn(
"size-4",
showBookmarksOnly && "fill-current",
)}
/>
</Button>
<Button
size="icon"
onClick={() =>
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</div>
</div>
) : templates.length === 0 ? (
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]">
<SearchIcon className="text-muted-foreground size-6" />
<div className="text-xl font-medium text-muted-foreground">
No templates found
{showBookmarksOnly
? "No bookmarked templates found"
: "No templates found"}
</div>
{showBookmarksOnly && (
<p className="text-sm text-muted-foreground">
Click the bookmark icon on templates to add them to
bookmarks
</p>
)}
</div>
) : (
<div
@@ -323,9 +394,25 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
viewMode === "detailed" && "h-[400px]",
)}
>
<Badge className="absolute top-2 right-2" variant="blue">
{template?.version}
</Badge>
<div className="absolute top-2 left-2 z-10">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={(e) => handleToggleBookmark(e, template.id)}
>
<Bookmark
className={cn(
"size-4",
bookmarkIds.includes(template.id) &&
"fill-yellow-400 text-yellow-400",
)}
/>
</Button>
</div>
<div className="absolute top-2 right-2">
<Badge variant="blue">{template?.version}</Badge>
</div>
<div
className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",

View File

@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
onClick={() => {
if (
stepper.current.id === "variant" &&
templateInfo.details
) {
setTemplateInfo((prev) => ({
...prev,
details: null,
}));
return;
}
stepper.prev();
}}
disabled={stepper.isFirst}
variant="secondary"
>

View File

@@ -88,7 +88,12 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}

View File

@@ -87,7 +87,12 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}

View File

@@ -166,6 +166,7 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +

View File

@@ -1,14 +1,19 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
redisId: string;
}
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
const { data } = api.redis.one.useQuery({ redisId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.redis.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -24,11 +29,21 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
value={data?.databasePassword}
disabled
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
redisId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.redis.one.invalidate({ redisId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">

View File

@@ -79,8 +79,11 @@ export const columns: ColumnDef<LogEntry>[] = [
: log.RequestPath}
</div>
<div className="flex flex-row gap-3 w-full">
<Badge variant={getStatusColor(log.OriginStatus)}>
Status: {formatStatusLabel(log.OriginStatus)}
<Badge
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
>
Status:{" "}
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
</Badge>
<Badge variant={"secondary"}>
Exec Time: {formatDuration(log.Duration)}

View File

@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
placeholder="Filter by hostname..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="md:max-w-sm"

View File

@@ -63,7 +63,7 @@ export const SearchCommand = () => {
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
if (e.code === "KeyJ" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/projects");
router.push("/dashboard/home");
setOpen(false);
}}
>

View File

@@ -2,12 +2,14 @@ import { loadStripe } from "@stripe/stripe-js";
import clsx from "clsx";
import {
AlertTriangle,
Bell,
CheckIcon,
CreditCard,
FileText,
Loader2,
MinusIcon,
PlusIcon,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -24,7 +26,17 @@ import {
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -89,6 +101,8 @@ export const ShowBilling = () => {
api.stripe.createCustomerPortalSession.useMutation();
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const utils = api.useUtils();
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
@@ -141,6 +155,7 @@ export const ShowBilling = () => {
return isAnnual ? interval === "year" : interval === "month";
});
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100);
@@ -149,14 +164,66 @@ export const ShowBilling = () => {
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</div>
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Bell className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Notification Settings</DialogTitle>
<DialogDescription>
Configure your billing email notifications.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="invoice-notifications">
Invoice Notifications
</Label>
<p className="text-sm text-muted-foreground">
Receive email notifications for payments and failed
charges.
</p>
</div>
<Switch
id="invoice-notifications"
checked={admin?.user.sendInvoiceNotifications ?? false}
onCheckedChange={async (checked) => {
await updateInvoiceNotifications({
enabled: checked,
})
.then(() => {
utils.user.get.invalidate();
toast.success(
checked
? "Invoice notifications enabled"
: "Invoice notifications disabled",
);
})
.catch(() => {
toast.error(
"Failed to update invoice notifications",
);
});
}}
/>
</div>
</DialogContent>
</Dialog>
)}
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
@@ -182,7 +249,7 @@ export const ShowBilling = () => {
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
{admin?.user.stripeSubscriptionId && (
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
<p className="text-sm text-muted-foreground">
@@ -203,8 +270,36 @@ export const ShowBilling = () => {
)}
</div>
)}
{isEnterpriseCloud && (
<div className="flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<ShieldCheck className="h-6 w-6 text-primary shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-foreground">
Enterprise Cloud Plan
</h3>
<p className="text-sm text-muted-foreground">
Your organization is on a managed Enterprise plan. Billing
is handled separately contact your account manager for
any changes.
</p>
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-fit mt-2"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
</div>
</div>
)}
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
{useNewPricing &&
{!isEnterpriseCloud &&
useNewPricing &&
data?.currentPlan === "legacy" &&
data?.subscriptions?.length > 0 && (
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
@@ -394,7 +489,8 @@ export const ShowBilling = () => {
</div>
)}
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
{useNewPricing &&
{!isEnterpriseCloud &&
useNewPricing &&
(data?.currentPlan === "hobby" ||
data?.currentPlan === "startup") &&
data?.subscriptions?.length > 0 && (
@@ -779,17 +875,18 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -923,22 +1020,24 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity <
STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1143,17 +1242,18 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
</div>
</div>
</section>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, PlusIcon } from "lucide-react";
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -47,108 +47,157 @@ const certificateDataHolder =
const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
const addCertificate = z.object({
const handleCertificateSchema = z.object({
name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"),
privateKey: z.string().min(1, "Private key is required"),
autoRenew: z.boolean().optional(),
serverId: z.string().optional(),
});
type AddCertificate = z.infer<typeof addCertificate>;
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;
export const AddCertificate = () => {
interface Props {
certificateId?: string;
}
export const HandleCertificate = ({ certificateId }: Props) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, isError, error, isPending } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
const form = useForm<AddCertificate>({
const { data: existingCert, refetch } = api.certificates.one.useQuery(
{ certificateId: certificateId || "" },
{ enabled: !!certificateId },
);
const createMutation = api.certificates.create.useMutation();
const updateMutation = api.certificates.update.useMutation();
const mutation = certificateId ? updateMutation : createMutation;
const { mutateAsync, isError, error, isPending } = mutation;
const form = useForm<HandleCertificateForm>({
defaultValues: {
name: "",
certificateData: "",
privateKey: "",
autoRenew: false,
},
resolver: zodResolver(addCertificate),
resolver: zodResolver(handleCertificateSchema),
});
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddCertificate) => {
await mutateAsync({
useEffect(() => {
if (existingCert) {
form.reset({
name: existingCert.name,
certificateData: existingCert.certificateData,
privateKey: existingCert.privateKey,
});
} else {
form.reset({
name: "",
certificateData: "",
privateKey: "",
});
}
}, [existingCert, form, open]);
const onSubmit = async (data: HandleCertificateForm) => {
const basePayload = {
name: data.name,
certificateData: data.certificateData,
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
})
};
const promise = certificateId
? updateMutation.mutateAsync({
certificateId,
...basePayload,
})
: createMutation.mutateAsync({
...basePayload,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
});
await promise
.then(async () => {
toast.success("Certificate Created");
toast.success(
certificateId ? "Certificate Updated" : "Certificate Created",
);
await utils.certificates.all.invalidate();
if (certificateId) {
refetch();
}
setOpen(false);
})
.catch(() => {
toast.error("Error creating the Certificate");
toast.error(
certificateId
? "Error updating the Certificate"
: "Error creating the Certificate",
);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
{" "}
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
<DialogTrigger asChild>
{certificateId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add New Certificate</DialogTitle>
<DialogTitle>
{certificateId ? "Update" : "Add New"} Certificate
</DialogTitle>
<DialogDescription>
Upload or generate a certificate to secure your application
{certificateId
? "Modify the certificate details"
: "Upload or generate a certificate to secure your application"}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-certificate"
id="hook-form-handle-certificate"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder={"My Certificate"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder="My Certificate" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="certificateData"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Certificate Data</FormLabel>
</div>
<FormLabel>Certificate Data</FormLabel>
<FormControl>
<Textarea
className="h-32"
@@ -165,9 +214,7 @@ export const AddCertificate = () => {
name="privateKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormLabel>Private Key</FormLabel>
<FormControl>
<Textarea
className="h-32"
@@ -248,10 +295,10 @@ export const AddCertificate = () => {
<DialogFooter className="flex w-full flex-row !justify-end">
<Button
isLoading={isPending}
form="hook-form-add-certificate"
form="hook-form-handle-certificate"
type="submit"
>
Create
{certificateId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -1,4 +1,14 @@
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Link,
Loader2,
Server,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -11,14 +21,20 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AddCertificate } from "./add-certificate";
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
import { HandleCertificate } from "./handle-certificate";
import {
extractLeafCommonName,
getCertificateChainExpirationDetails,
getCertificateChainInfo,
getExpirationStatus,
} from "./utils";
export const ShowCertificates = () => {
const { mutateAsync, isPending: isRemoving } =
api.certificates.remove.useMutation();
const { data, isPending, refetch } = api.certificates.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
return (
<div className="w-full">
@@ -54,7 +70,7 @@ export const ShowCertificates = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any certificates created
</span>
{permissions?.certificate.create && <AddCertificate />}
{permissions?.certificate.create && <HandleCertificate />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -66,6 +82,30 @@ export const ShowCertificates = () => {
const chainInfo = getCertificateChainInfo(
certificate.certificateData,
);
const commonName = extractLeafCommonName(
certificate.certificateData,
);
const chainDetails = chainInfo.isChain
? getCertificateChainExpirationDetails(
certificate.certificateData,
)
: null;
const isExpanded = expandedChains.has(
certificate.certificateId,
);
const toggleChain = () => {
setExpandedChains((prev) => {
const next = new Set(prev);
if (next.has(certificate.certificateId)) {
next.delete(certificate.certificateId);
} else {
next.add(certificate.certificateId);
}
return next;
});
};
return (
<div
key={certificate.certificateId}
@@ -77,12 +117,58 @@ export const ShowCertificates = () => {
<span className="text-sm font-medium">
{index + 1}. {certificate.name}
</span>
{commonName && (
<span className="text-xs text-muted-foreground">
CN: {commonName}
</span>
)}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Server className="size-3" />
{certificate.server
? `${certificate.server.name} (${certificate.server.ipAddress})`
: "Dokploy (Local)"}
</span>
{chainInfo.isChain && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count})
</span>
<div className="flex flex-col gap-1.5 mt-1">
<button
type="button"
onClick={toggleChain}
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 w-fit hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
)}
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count} certificates)
</span>
</button>
{isExpanded && (
<div className="flex flex-col gap-3 pl-2 border-l-2 border-muted">
{chainDetails?.map((cert) => (
<div
key={cert.index}
className="flex flex-col gap-1 p-2 rounded-md bg-muted/30"
>
<span className="text-xs font-medium text-muted-foreground">
{cert.label}
</span>
{cert.commonName && (
<span className="text-xs text-muted-foreground/80">
CN: {cert.commonName}
</span>
)}
<span
className={`text-xs ${cert.className}`}
>
{cert.message}
</span>
</div>
))}
</div>
)}
</div>
)}
<div
@@ -102,8 +188,14 @@ export const ShowCertificates = () => {
</div>
</div>
{permissions?.certificate.delete && (
<div className="flex flex-row gap-1">
<div className="flex flex-row gap-1">
{permissions?.certificate.update && (
<HandleCertificate
certificateId={certificate.certificateId}
/>
)}
{permissions?.certificate.delete && (
<DialogAction
title="Delete Certificate"
description="Are you sure you want to delete this certificate?"
@@ -129,14 +221,14 @@ export const ShowCertificates = () => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
)}
)}
</div>
</div>
</div>
);
@@ -145,7 +237,7 @@ export const ShowCertificates = () => {
{permissions?.certificate.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate />
<HandleCertificate />
</div>
)}
</div>

View File

@@ -1,5 +1,13 @@
// @ts-nocheck
// Split certificate chain into individual certificates
export const splitCertificateChain = (certData: string): string[] => {
const certRegex =
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
const matches = certData.match(certRegex);
return matches || [];
};
export const extractExpirationDate = (certData: string): Date | null => {
try {
// Decode PEM base64 to DER binary
@@ -28,11 +36,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
@@ -44,15 +52,14 @@ export const extractExpirationDate = (certData: string): Date | null => {
// Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02)
throw new Error("Unexpected structure");
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
if (der[offset++] !== 0x30) return null;
const validityLen = readLength(offset);
offset = validityLen.offset;
@@ -94,8 +101,156 @@ export const extractExpirationDate = (certData: string): Date | null => {
}
};
export const extractCommonName = (certData: string): string | null => {
try {
// Decode PEM base64 to DER binary
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const binStr = atob(b64);
const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
der[i] = binStr.charCodeAt(i);
}
let offset = 0;
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
// biome-ignore lint/style/noParameterAssign: <explanation>
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
// biome-ignore lint/style/noParameterAssign: <explanation>
len = (len << 8) + der[pos++];
}
}
return { length: len, offset: pos };
}
// Helper: skip a field
function skipField(pos: number): number {
// biome-ignore lint/style/noParameterAssign: <explanation>
pos++;
const fieldLen = readLength(pos);
return fieldLen.offset + fieldLen.length;
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber
offset = skipField(offset);
// Skip signature
offset = skipField(offset);
// Skip issuer
offset = skipField(offset);
// Skip validity
offset = skipField(offset);
// Subject sequence - where we find the CN
if (der[offset++] !== 0x30) return null;
const subjectLen = readLength(offset);
const subjectEnd = subjectLen.offset + subjectLen.length;
offset = subjectLen.offset;
// Parse subject RDNs looking for CN (OID 2.5.4.3)
while (offset < subjectEnd) {
if (der[offset++] !== 0x31) continue; // SET
const setLen = readLength(offset);
offset = setLen.offset;
if (der[offset++] !== 0x30) continue; // SEQUENCE
const seqLen = readLength(offset);
offset = seqLen.offset;
if (der[offset++] !== 0x06) continue; // OID
const oidLen = readLength(offset);
offset = oidLen.offset;
// Check if OID is 2.5.4.3 (commonName)
const oid = Array.from(der.slice(offset, offset + oidLen.length));
offset += oidLen.length;
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
if (
oid.length === 3 &&
oid[0] === 0x55 &&
oid[1] === 0x04 &&
oid[2] === 0x03
) {
// Next should be the string value
const strType = der[offset++];
const strLen = readLength(offset);
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
return new TextDecoder().decode(cnBytes);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate CN:", error);
return null;
}
};
// Extract the Common Name from the first (leaf) certificate in a chain
export const extractLeafCommonName = (certData: string): string | null => {
const certs = splitCertificateChain(certData);
if (certs.length === 0) return null;
return extractCommonName(certs[0]);
};
// Extract expiration dates from all certificates in a chain
export const extractAllExpirationDates = (
certData: string,
): Array<{
cert: string;
index: number;
expirationDate: Date | null;
commonName: string | null;
}> => {
const certs = splitCertificateChain(certData);
return certs.map((cert, index) => ({
cert,
index,
expirationDate: extractExpirationDate(cert),
commonName: extractCommonName(cert),
}));
};
// Get the earliest expiration date from a certificate chain
export const getEarliestExpirationDate = (certData: string): Date | null => {
const expirationDates = extractAllExpirationDates(certData);
const validDates = expirationDates
.filter((item) => item.expirationDate !== null)
.map((item) => item.expirationDate as Date);
if (validDates.length === 0) return null;
return new Date(Math.min(...validDates.map((date) => date.getTime())));
};
export const getExpirationStatus = (certData: string) => {
const expirationDate = extractExpirationDate(certData);
const chainInfo = getCertificateChainInfo(certData);
const expirationDate = chainInfo.isChain
? getEarliestExpirationDate(certData)
: extractExpirationDate(certData);
if (!expirationDate)
return {
@@ -153,3 +308,67 @@ export const getCertificateChainInfo = (certData: string) => {
count: 1,
};
};
// Get detailed expiration information for all certificates in a chain
export const getCertificateChainExpirationDetails = (certData: string) => {
const allExpirations = extractAllExpirationDates(certData);
const now = new Date();
return allExpirations.map(({ index, expirationDate, commonName }) => {
if (!expirationDate) {
return {
index,
label: `Certificate ${index + 1}`,
commonName,
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
expirationDate: null,
};
}
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
let status: "expired" | "warning" | "valid";
let className: string;
let message: string;
if (daysUntilExpiration < 0) {
status = "expired";
className = "text-red-500";
message = `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`;
} else if (daysUntilExpiration <= 30) {
status = "warning";
className = "text-yellow-500";
message = `Expires in ${daysUntilExpiration} days`;
} else {
status = "valid";
className = "text-muted-foreground";
message = `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`;
}
return {
index,
label:
index === 0
? `Certificate ${index + 1} (Leaf)`
: `Certificate ${index + 1}`,
commonName,
status,
className,
message,
expirationDate,
daysUntilExpiration,
};
});
};

View File

@@ -1,3 +1,7 @@
import {
ADDITIONAL_FLAG_ERROR,
ADDITIONAL_FLAG_REGEX,
} from "@dokploy/server/db/validations/destination";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
@@ -35,10 +39,6 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import {
ADDITIONAL_FLAG_ERROR,
ADDITIONAL_FLAG_REGEX,
} from "@dokploy/server/db/validations/destination";
import { S3_PROVIDERS } from "./constants";
const addDestination = z.object({

View File

@@ -283,7 +283,7 @@ export const AddGitlabProvider = () => {
</FormLabel>
<FormControl>
<Input
placeholder="For organization/group access use the slugish name of the group eg: my-org"
placeholder="For organization/group access use the slug name of the group eg: my-org"
{...field}
/>
</FormControl>

View File

@@ -192,7 +192,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
</FormLabel>
<FormControl>
<Input
placeholder="For organization/group access use the slugish name of the group eg: my-org"
placeholder="For organization/group access use the slug name of the group eg: my-org"
{...field}
/>
</FormControl>

View File

@@ -5,6 +5,7 @@ import {
ImportIcon,
Loader2,
Trash2,
Users,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
@@ -24,6 +25,13 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
@@ -39,6 +47,8 @@ export const ShowGitProviders = () => {
const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } =
api.gitProvider.remove.useMutation();
const { mutateAsync: toggleShare, isPending: isToggling } =
api.gitProvider.toggleShare.useMutation();
const url = useUrl();
const getGitlabUrl = (
@@ -154,10 +164,62 @@ export const ShowGitProviders = () => {
)}
</span>
</div>
{!gitProvider.isOwner && (
<Badge
variant="secondary"
className="text-xs"
>
<Users className="size-3 mr-1" />
Shared
</Badge>
)}
</div>
</div>
<div className="flex flex-row gap-1 items-center">
{gitProvider.isOwner && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 mr-2">
<Users className="size-4 text-muted-foreground" />
<Switch
disabled={isToggling}
checked={
gitProvider.sharedWithOrganization
}
onCheckedChange={async (
checked,
) => {
await toggleShare({
gitProviderId:
gitProvider.gitProviderId,
sharedWithOrganization: checked,
})
.then(() => {
toast.success(
checked
? "Provider shared with organization"
: "Provider unshared",
);
refetch();
})
.catch(() => {
toast.error(
"Error updating sharing",
);
});
}}
/>
</div>
</TooltipTrigger>
<TooltipContent>
Share with entire organization
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
@@ -222,62 +284,71 @@ export const ShowGitProviders = () => {
</div>
)}
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
/>
)}
{gitProvider.isOwner && (
<>
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
<DialogAction
title="Delete Git Provider"
description="Are you sure you want to delete this Git Provider?"
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId: gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<DialogAction
title="Delete Git Provider"
description={
gitProvider.sharedWithOrganization
? "This provider is shared with the organization. Deleting it will remove access for all members. Are you sure?"
: "Are you sure you want to delete this Git Provider?"
}
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId:
gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
</div>

View File

@@ -1,6 +1,13 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
import {
Check,
ChevronDown,
Loader2,
PenBoxIcon,
Plug,
PlusIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -37,10 +44,34 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const AI_PROVIDERS = [
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
{
name: "Google Gemini",
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
},
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
{ name: "Ollama", apiUrl: "http://localhost:11434" },
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
] as const;
const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
@@ -103,7 +134,7 @@ export const HandleAi = ({ aiId }: Props) => {
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const {
data: models,
isPending: isLoadingServerModels,
isFetching: isLoadingServerModels,
error: modelsError,
} = api.ai.getModels.useQuery(
{
@@ -172,6 +203,34 @@ export const HandleAi = ({ aiId }: Props) => {
<AlertBlock type="error">{modelsError.message}</AlertBlock>
)}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<div className="space-y-1">
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
form.setValue("model", "");
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={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
</p>
</div>
<FormField
control={form.control}
name="name"
@@ -253,101 +312,129 @@ export const HandleAi = ({ aiId }: Props) => {
</span>
)}
{!isLoadingServerModels && !models?.length && (
<span className="text-sm text-muted-foreground">
No models available
</span>
)}
<FormField
control={form.control}
name="model"
render={({ field }) => {
const hasModels =
!isLoadingServerModels && models && models.length > 0;
const selectedModel = models?.find((m) => m.id === field.value);
const filteredModels = (models ?? []).filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
{!isLoadingServerModels && models && models.length > 0 && (
<FormField
control={form.control}
name="model"
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
// Ensure selected model is always in the filtered list
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
return (
<FormItem>
<FormLabel>Model</FormLabel>
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
return (
<FormItem>
<FormLabel>Model</FormLabel>
<div className="flex gap-2">
<div className="flex-1">
{hasModels ? (
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[400px] p-0"
align="start"
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
<Command>
<CommandInput
placeholder="Search or type a custom model..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>
{modelSearch ? (
<button
type="button"
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
onClick={() => {
field.onChange(modelSearch);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
Use custom model: "{modelSearch}"
</button>
) : (
"No models found."
)}
</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<FormControl>
<Input
placeholder={
isLoadingServerModels
? "Loading models..."
: "Enter model name (e.g. gpt-4o)"
}
disabled={isLoadingServerModels}
{...field}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select an AI model to use
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
</FormControl>
)}
</div>
</div>
<FormDescription>
Select a model from the list or type a custom model name
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
@@ -372,7 +459,12 @@ export const HandleAi = ({ aiId }: Props) => {
)}
/>
<div className="flex justify-end gap-2 pt-4">
<div className="flex justify-end gap-2 pt-4">
<TestConnectionButton
apiUrl={apiUrl}
apiKey={apiKey}
model={form.watch("model")}
/>
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
</Button>
@@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => {
</Dialog>
);
};
function TestConnectionButton({
apiUrl,
apiKey,
model,
}: {
apiUrl: string;
apiKey: string;
model: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
toast.success("Connection successful");
},
onError: (error) => {
toast.error("Connection failed", {
description: error.message,
});
},
});
const isDisabled = !apiUrl || !model;
return (
<Button
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plug className="mr-2 h-4 w-4" />
)}
Test Connection
</Button>
);
}

View File

@@ -12,9 +12,9 @@ import { toast } from "sonner";
import { z } from "zod";
import {
DiscordIcon,
MattermostIcon,
GotifyIcon,
LarkIcon,
MattermostIcon,
NtfyIcon,
PushoverIcon,
ResendIcon,
@@ -54,6 +54,7 @@ const notificationBaseSchema = z.object({
appDeploy: z.boolean().default(false),
appBuildError: z.boolean().default(false),
databaseBackup: z.boolean().default(false),
dokployBackup: z.boolean().default(false),
volumeBackup: z.boolean().default(false),
dokployRestart: z.boolean().default(false),
dockerCleanup: z.boolean().default(false),
@@ -355,6 +356,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
webhookUrl: notification.slack?.webhookUrl,
@@ -369,6 +371,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
botToken: notification.telegram?.botToken,
messageThreadId: notification.telegram?.messageThreadId || "",
@@ -384,6 +387,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
@@ -398,6 +402,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
smtpServer: notification.email?.smtpServer,
@@ -416,6 +421,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
apiKey: notification.resend?.apiKey,
@@ -431,6 +437,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
@@ -446,6 +453,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken || "",
@@ -462,6 +470,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.mattermost?.webhookUrl,
@@ -477,6 +486,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
type: notification.notificationType,
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
@@ -490,6 +500,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl,
@@ -503,6 +514,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
type: notification.notificationType,
endpoint: notification.custom?.endpoint || "",
headers: notification.custom?.headers
@@ -524,6 +536,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
userKey: notification.pushover?.userKey,
@@ -562,6 +575,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy,
dokployRestart,
databaseBackup,
dokployBackup,
volumeBackup,
dockerCleanup,
serverThreshold,
@@ -573,6 +587,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
channel: data.channel,
@@ -588,6 +603,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
botToken: data.botToken,
messageThreadId: data.messageThreadId || "",
@@ -604,6 +620,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
decoration: data.decoration,
@@ -619,6 +636,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
smtpServer: data.smtpServer,
smtpPort: data.smtpPort,
@@ -638,6 +656,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
apiKey: data.apiKey,
fromAddress: data.fromAddress,
@@ -654,6 +673,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
appToken: data.appToken,
@@ -670,6 +690,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken || "",
@@ -686,6 +707,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
channel: data.channel || undefined,
@@ -702,6 +724,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
@@ -716,6 +739,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
@@ -742,6 +766,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
endpoint: data.endpoint,
headers: headersRecord,
@@ -761,6 +786,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup,
userKey: data.userKey,
apiToken: data.apiToken,
@@ -1856,6 +1882,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)}
/>
<FormField
control={form.control}
name="dokployBackup"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Dokploy Backup</FormLabel>
<FormDescription>
Trigger the action when a dokploy backup is created.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="volumeBackup"

View File

@@ -25,11 +25,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { InputOTP } from "@/components/ui/input-otp";
import {
Tooltip,
TooltipContent,
@@ -423,23 +419,14 @@ export const Enable2FA = () => {
)}
</div>
<div className="flex flex-col justify-center items-center">
<div className="flex flex-col gap-2">
<FormLabel>Verification Code</FormLabel>
<InputOTP
maxLength={6}
value={otpValue}
onChange={setOtpValue}
autoComplete="off"
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
autoFocus
/>
<FormDescription>
Enter the 6-digit code from your authenticator app
</FormDescription>

View File

@@ -1,7 +1,14 @@
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;
@@ -52,7 +59,36 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
return (
<div className="flex items-center gap-4">
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
<Label className="text-primary">Daily Docker Cleanup</Label>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
Daily Docker Cleanup
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p>
Runs a full Docker cleanup daily, pruning stopped containers,
unused images, volumes, build cache, and system resources. This
may remove images built for Compose services that run on-demand
(backup runners, cron jobs, one-off tasks).
</p>
<p className="mt-1">
For custom cleanup strategies, use{" "}
<a
href="https://docs.dokploy.com/docs/core/schedule-jobs#example-1-automatic-docker-cleanup"
target="_blank"
rel="noopener noreferrer"
className="underline text-primary"
>
Schedule Jobs
</a>{" "}
on your web server or remote servers.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

View File

@@ -161,7 +161,7 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
<ul>
<li>
1. Add the public SSH Key when you create a server in your
preffered provider (Hostinger, Digital Ocean, Hetzner,
preferred provider (Hostinger, Digital Ocean, Hetzner,
etc){" "}
</li>
<li>2. Add The SSH Key to Server Manually</li>

View File

@@ -48,7 +48,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSchedulesModal } from "./show-schedules-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
export const ShowServers = () => {
const router = useRouter();
@@ -63,7 +63,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSuscription />}
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">

View File

@@ -1,6 +1,9 @@
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props {
@@ -21,9 +24,24 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);

View File

@@ -51,11 +51,12 @@ export const { useStepper, steps, Scoped } = defineStepper(
{ id: "complete", title: "Complete", description: "Checkout complete" },
);
export const WelcomeSuscription = () => {
export const WelcomeSubscription = () => {
const [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const { push } = useRouter();
const router = useRouter();
const { push } = router;
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
@@ -66,7 +67,22 @@ export const WelcomeSuscription = () => {
}, [showConfetti]);
return (
<Dialog open={isOpen}>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
const { success, ...rest } = router.query;
router.replace(
{ pathname: router.pathname, query: rest },
undefined,
{
shallow: true,
},
);
}
}}
>
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">
@@ -409,7 +425,7 @@ export const WelcomeSuscription = () => {
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/projects");
push("/dashboard/home");
} else {
stepper.next();
}

View File

@@ -34,14 +34,63 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
const addInvitation = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.string().min(1, "Role is required"),
notificationId: z.string().optional(),
});
const addInvitation = z
.object({
mode: z.enum(["invitation", "credentials"]),
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.string().min(1, "Role is required"),
notificationId: z.string().optional(),
password: z.string().optional(),
confirmPassword: z.string().optional(),
})
.superRefine((value, ctx) => {
if (value.mode !== "credentials") {
return;
}
if (!value.password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password is required",
path: ["password"],
});
} else if (value.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters",
path: ["password"],
});
}
if (!value.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Confirm password is required",
path: ["confirmPassword"],
});
} else if (value.confirmPassword.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters",
path: ["confirmPassword"],
});
}
if (
value.password &&
value.confirmPassword &&
value.password !== value.confirmPassword
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});
type AddInvitation = z.infer<typeof addInvitation>;
@@ -54,50 +103,83 @@ export const AddInvitation = () => {
const { mutateAsync: inviteMember, isPending: isInviting } =
api.organization.inviteMember.useMutation();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
api.user.createUserWithCredentials.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const [error, setError] = useState<string | null>(null);
const form = useForm<AddInvitation>({
defaultValues: {
mode: "invitation",
email: "",
role: "member",
notificationId: "",
password: "",
confirmPassword: "",
},
resolver: zodResolver(addInvitation),
});
const mode = form.watch("mode");
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddInvitation) => {
try {
const result = await inviteMember({
email: data.email.toLowerCase(),
role: data.role,
});
if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result!.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setError(null);
setOpen(false);
} catch (error: any) {
setError(error.message || "Failed to create invitation");
useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") {
form.setValue("mode", "invitation");
}
}, [form, isCloud]);
utils.organization.allInvitations.invalidate();
const onSubmit = async (data: AddInvitation) => {
setError(null);
try {
if (data.mode === "credentials") {
await createUserWithCredentials({
email: data.email.toLowerCase(),
password: data.password!,
role: data.role,
});
toast.success("User created with initial credentials");
setOpen(false);
} else {
const result = await inviteMember({
email: data.email.toLowerCase(),
role: data.role,
});
if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result!.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setOpen(false);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to create user";
setError(message);
toast.error(message);
} finally {
await Promise.all([
utils.organization.allInvitations.invalidate(),
utils.user.all.invalidate(),
]);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
@@ -108,7 +190,11 @@ export const AddInvitation = () => {
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
<DialogDescription>
{mode === "credentials"
? "Create a user with initial credentials"
: "Invite a new user"}
</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error}</AlertBlock>}
@@ -118,6 +204,43 @@ export const AddInvitation = () => {
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
{!isCloud && (
<FormField
control={form.control}
name="mode"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Invite Method</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select invite method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="invitation">
Invitation Link
</SelectItem>
<SelectItem value="credentials">
Initial Credentials
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose between invitation link flow or direct
credentials provisioning
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<FormField
control={form.control}
name="email"
@@ -172,7 +295,7 @@ export const AddInvitation = () => {
}}
/>
{!isCloud && (
{!isCloud && mode === "invitation" && (
<FormField
control={form.control}
name="notificationId"
@@ -212,9 +335,57 @@ export const AddInvitation = () => {
}}
/>
)}
{!isCloud && mode === "credentials" && (
<>
<FormField
control={form.control}
name="password"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter initial password"
{...field}
/>
</FormControl>
<FormDescription>
The user can sign in with this password immediately
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm initial password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isInviting}
isLoading={isInviting || isCreating}
form="hook-form-add-invitation"
type="submit"
>

View File

@@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { api, type RouterOutputs } from "@/utils/api";
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
@@ -170,6 +171,8 @@ const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(),
accessedServers: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
@@ -196,6 +199,19 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
enabled: isOpen,
});
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const { data: gitProviders } = api.gitProvider.allForPermissions.useQuery(
undefined,
{
enabled: isOpen && !!haveValidLicense,
},
);
const { data: servers } = api.server.allForPermissions.useQuery(undefined, {
enabled: isOpen && !!haveValidLicense,
});
const { data, refetch } = api.user.one.useQuery(
{
@@ -214,6 +230,8 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedProjects: [],
accessedEnvironments: [],
accessedServices: [],
accessedGitProviders: [],
accessedServers: [],
canDeleteEnvironments: false,
canCreateProjects: false,
canCreateServices: false,
@@ -235,6 +253,8 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
accessedGitProviders: data.accessedGitProviders || [],
accessedServers: data.accessedServers || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
@@ -262,6 +282,8 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
accessedGitProviders: data.accessedGitProviders || [],
accessedServers: data.accessedServers || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
@@ -870,6 +892,151 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
</FormItem>
)}
/>
{haveValidLicense ? (
<FormField
control={form.control}
name="accessedGitProviders"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">Git Providers</FormLabel>
<FormDescription>
Select the Git Providers that the user can access
</FormDescription>
</div>
{gitProviders?.length === 0 && (
<p className="text-sm text-muted-foreground">
No git providers found
</p>
)}
<div className="grid md:grid-cols-1 gap-2">
{gitProviders?.map((provider) => (
<FormField
key={provider.gitProviderId}
control={form.control}
name="accessedGitProviders"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
<FormControl>
<Checkbox
checked={field.value?.includes(
provider.gitProviderId,
)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([
...(field.value || []),
provider.gitProviderId,
]);
} else {
field.onChange(
field.value?.filter(
(v) => v !== provider.gitProviderId,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<FormLabel className="text-sm cursor-pointer">
{provider.name}
</FormLabel>
<span className="text-xs text-muted-foreground capitalize">
({provider.providerType})
</span>
</div>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
) : (
<div className="md:col-span-2">
<EnterpriseFeatureLocked
compact
title="Git Provider Assignment"
description="Assign specific Git Providers to users with an Enterprise license."
/>
</div>
)}
{haveValidLicense ? (
<FormField
control={form.control}
name="accessedServers"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">Servers</FormLabel>
<FormDescription>
Select the Servers that the user can access
</FormDescription>
</div>
{servers?.length === 0 && (
<p className="text-sm text-muted-foreground">
No servers found
</p>
)}
<div className="grid md:grid-cols-1 gap-2">
{servers?.map((s) => (
<FormField
key={s.serverId}
control={form.control}
name="accessedServers"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
<FormControl>
<Checkbox
checked={field.value?.includes(s.serverId)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([
...(field.value || []),
s.serverId,
]);
} else {
field.onChange(
field.value?.filter(
(v) => v !== s.serverId,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<FormLabel className="text-sm cursor-pointer">
{s.name}
</FormLabel>
<span className="text-xs text-muted-foreground">
({s.ipAddress})
</span>
<span className="text-xs text-muted-foreground capitalize">
{s.serverType}
</span>
</div>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
) : (
<div className="md:col-span-2">
<EnterpriseFeatureLocked
compact
title="Server Assignment"
description="Assign specific Servers to users with an Enterprise license."
/>
</div>
)}
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
<Button
isLoading={isPending}

View File

@@ -153,7 +153,7 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
)}
<br />
<em className="text-muted-foreground text-xs">
Note: Owner role is intransferible.
Note: Owner role is nontransferable.
</em>
</FormDescription>
<FormMessage />

View File

@@ -122,7 +122,7 @@ export const ShowUsers = () => {
// Can change role based on hierarchy:
// - Owner: Can change anyone's role (except themselves and other owners)
// - Admin: Can only change member/custom roles (not other admins or owners)
// - Owner role is intransferible
// - Owner role is nontransferable
const canChangeRole =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&

View File

@@ -87,7 +87,12 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && !canEdit) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
!canEdit
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}

View File

@@ -0,0 +1,98 @@
import { AlertCircle, HardDrive, Network } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { TableCell, TableRow } from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ContainerInfo, ContainerStat } from "./types";
import { formatCpu, formatIOValue, formatMemUsage } from "./utils";
interface ContainerRowProps {
container: ContainerInfo;
stat: ContainerStat | undefined;
}
export const ContainerRow = ({ container, stat }: ContainerRowProps) => {
const isRunning = container.CurrentState.startsWith("Running");
const hasError = container.Error && container.Error.trim() !== "";
const stateBadge = (
<Badge
variant={hasError ? "destructive" : isRunning ? "default" : "destructive"}
>
{container.CurrentState}
</Badge>
);
return (
<TableRow>
<TableCell>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{container.Name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[230px]">
{container.Image}
</span>
</div>
</TableCell>
<TableCell>
{hasError ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1.5 cursor-help">
{stateBadge}
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs font-medium">Error:</p>
<p className="text-xs">{container.Error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
stateBadge
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<span className="text-sm font-medium">{formatCpu(stat.CPUPerc)}</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<span className="text-sm font-medium">
{formatMemUsage(stat.MemUsage)}
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<div className="flex items-center justify-end gap-1.5">
<HardDrive className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{formatIOValue(stat.BlockIO)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<div className="flex items-center justify-end gap-1.5">
<Network className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{formatIOValue(stat.NetIO)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
</TableRow>
);
};

View File

@@ -0,0 +1,277 @@
import {
AlertCircle,
AlertTriangle,
ExternalLink,
Info,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import type { ContainerInfo } from "./types";
export const DocLinks = () => (
<div className="flex flex-col gap-1 pt-2 border-t mt-2">
<p className="text-xs font-medium text-muted-foreground">
Helpful resources:
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<a
href="https://docs.dokploy.com/docs/core"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Dokploy Documentation
<ExternalLink className="h-3 w-3" />
</a>
<a
href="https://docs.docker.com/engine/swarm/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Docker Swarm Guide
<ExternalLink className="h-3 w-3" />
</a>
<Link
href="/dashboard/settings/cluster"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Cluster Settings
</Link>
</div>
</div>
);
interface SwarmNotAvailableProps {
errorMessage?: string;
onRetry: () => void;
}
export const SwarmNotAvailable = ({
errorMessage,
onRetry,
}: SwarmNotAvailableProps) => (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Swarm Not Available</AlertTitle>
<AlertDescription>
Could not reach Docker Swarm.{" "}
{errorMessage && (
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
)}
</AlertDescription>
</Alert>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
This feature requires Docker Swarm to be initialized and active. To get
started:
</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>
Initialize Swarm on your server:{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
docker swarm init
</code>
</li>
<li>
Verify it&apos;s active:{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
docker info | grep Swarm
</code>
</li>
<li>
Check the{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
</Link>{" "}
page to manage your swarm nodes
</li>
</ol>
<DocLinks />
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
interface ServicesErrorProps {
errorMessage?: string;
onRetry: () => void;
}
export const ServicesError = ({
errorMessage,
onRetry,
}: ServicesErrorProps) => (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Failed to Load Services</AlertTitle>
<AlertDescription>
Swarm is reachable but service listing failed.{" "}
{errorMessage && (
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
)}
</AlertDescription>
</Alert>
<div className="space-y-3 text-sm text-muted-foreground">
<p>This could be caused by:</p>
<ul className="list-disc list-inside space-y-1 ml-1">
<li>Permission issues running Docker commands on the server</li>
<li>Docker daemon not responding</li>
<li>
Network connectivity issues to a remote server &mdash; check{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
</Link>
</li>
</ul>
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
interface NoServicesProps {
nodeCount: number;
onRefresh: () => void;
}
export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>No Swarm Services Found</AlertTitle>
<AlertDescription>
Docker Swarm is active with <strong>{nodeCount} node(s)</strong>, but
there are no application services running in the swarm.
</AlertDescription>
</Alert>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
This view shows containers deployed as <strong>Swarm services</strong>.
Standalone or Docker Compose containers won&apos;t appear here.
</p>
<p>To see containers in this view, make sure your applications are:</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>
<strong>Deployed as Swarm services</strong> &mdash; Applications in
Dokploy deploy to Swarm by default. Docker Compose projects need to
use{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">Stack</code>{" "}
type (not{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
Docker Compose
</code>
) to run as Swarm services.
</li>
<li>
<strong>Using a registry</strong> (for multi-node setups) &mdash;
Worker nodes need to pull images from a shared registry. Configure one
in{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
</Link>
.
</li>
<li>
<strong>Successfully built and deployed</strong> &mdash; Check your
project&apos;s deployment logs for errors.
</li>
</ol>
<DocLinks />
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
);
interface NoRunningContainersProps {
serviceCount: number;
containers: ContainerInfo[];
onRefresh: () => void;
}
export const NoRunningContainers = ({
serviceCount,
containers,
onRefresh,
}: NoRunningContainersProps) => {
const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== "");
return (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>No Running Containers</AlertTitle>
<AlertDescription>
Found <strong>{serviceCount} service(s)</strong> in the swarm, but
none have running containers.
</AlertDescription>
</Alert>
{hasErrors && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Container Errors Detected</AlertTitle>
<AlertDescription>
<ul className="list-disc list-inside space-y-1 mt-1">
{containers
.filter((c) => c.Error && c.Error.trim() !== "")
.slice(0, 5)
.map((c) => (
<li key={c.ID} className="text-xs">
<strong>{c.Name}</strong>: {c.Error}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="space-y-3 text-sm text-muted-foreground">
<p>This can happen when:</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li>Services are scaled to 0 replicas</li>
<li>
Containers are failing to start &mdash; check deployment logs for
errors
</li>
<li>
Images can&apos;t be pulled on worker nodes &mdash; verify your{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
registry configuration
</Link>
</li>
<li>
Node constraints prevent scheduling &mdash; check placement rules in
your app&apos;s Cluster settings
</li>
</ul>
<DocLinks />
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
);
};

View File

@@ -0,0 +1,128 @@
import { ChevronDown, ChevronRight, Server } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContainerRow } from "./container-row";
import type { ContainerStat, NodeGroup } from "./types";
interface NodeSectionProps {
group: NodeGroup;
isExpanded: boolean;
onToggleNode: (nodeName: string) => void;
findStatsForContainer: (taskName: string) => ContainerStat | undefined;
}
export const NodeSection = ({
group,
isExpanded,
onToggleNode,
findStatsForContainer,
}: NodeSectionProps) => {
const runningCount = group.containers.filter((c) =>
c.CurrentState.startsWith("Running"),
).length;
const nodeDown =
group.nodeStatus &&
(group.nodeStatus.Status !== "Ready" ||
group.nodeStatus.Availability !== "Active");
return (
<Collapsible
open={isExpanded}
onOpenChange={() => onToggleNode(group.nodeName)}
>
<Card className="bg-background">
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors rounded-t-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div className="relative">
<Server className="h-5 w-5 text-muted-foreground" />
{nodeDown && (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</div>
<CardTitle className="text-base">{group.nodeName}</CardTitle>
{group.nodeStatus && (
<Badge
variant={
group.nodeStatus.ManagerStatus === "Leader"
? "default"
: group.nodeStatus.ManagerStatus === "Reachable"
? "secondary"
: "outline"
}
className="text-[10px]"
>
{group.nodeStatus.ManagerStatus || "Worker"}
</Badge>
)}
<Badge variant="secondary">
{group.containers.length} container
{group.containers.length !== 1 ? "s" : ""}
</Badge>
{nodeDown ? (
<Badge variant="destructive">
{group.nodeStatus?.Status} /{" "}
{group.nodeStatus?.Availability}
</Badge>
) : runningCount === group.containers.length ? (
<Badge variant="default">All Running</Badge>
) : (
<Badge variant="orange">
{runningCount}/{group.containers.length} Running
</Badge>
)}
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Container</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">CPU</TableHead>
<TableHead className="text-right">Memory</TableHead>
<TableHead className="text-right">Block I/O</TableHead>
<TableHead className="text-right">Network I/O</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.containers.map((container) => {
const stat = findStatsForContainer(container.Name);
return (
<ContainerRow
key={container.ID}
container={container}
stat={stat}
/>
);
})}
</TableBody>
</Table>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
};

View File

@@ -0,0 +1,371 @@
import {
AlertTriangle,
Container,
Info,
Loader2,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api";
import {
NoRunningContainers,
NoServices,
ServicesError,
SwarmNotAvailable,
} from "./empty-states";
import { NodeSection } from "./node-section";
import { SummaryCards } from "./summary-cards";
import type { ContainerInfo, ContainerStat, SwarmNode } from "./types";
interface Props {
serverId?: string;
}
export const ShowSwarmContainers = ({ serverId }: Props) => {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const {
data: nodes,
isLoading: nodesLoading,
isError: nodesError,
error: nodesErrorDetail,
refetch: refetchNodes,
} = api.swarm.getNodes.useQuery({ serverId });
const {
data: nodeApps,
isLoading: appsLoading,
isError: appsError,
error: appsErrorDetail,
refetch: refetchApps,
} = api.swarm.getNodeApps.useQuery(
{ serverId },
{ enabled: !nodesError && nodes !== undefined },
);
const applicationList =
nodeApps && nodeApps.length > 0
? nodeApps.map((app: { Name: string }) => app.Name)
: [];
const {
data: appDetails,
isLoading: detailsLoading,
refetch: refetchDetails,
} = api.swarm.getAppInfos.useQuery(
{ appName: applicationList, serverId },
{ enabled: applicationList.length > 0 },
);
const { data: stats, isLoading: statsLoading } =
api.swarm.getContainerStats.useQuery(
{ serverId },
{
refetchInterval: 5000,
enabled: applicationList.length > 0 && !nodesError && !appsError,
},
);
const isLoading =
nodesLoading ||
appsLoading ||
(applicationList.length > 0 && detailsLoading);
// Build container list
const containers: ContainerInfo[] = [];
if (nodeApps && appDetails) {
for (const app of nodeApps) {
const details =
appDetails?.filter((detail: { Name: string }) =>
detail.Name.startsWith(`${app.Name}.`),
) || [];
if (details.length === 0) {
containers.push({
...app,
CurrentState: "N/A",
DesiredState: "N/A",
Error: "",
Node: "N/A",
ID: app.ID,
});
} else {
for (const detail of details) {
containers.push({
Name: detail.Name,
Image: detail.Image || app.Image,
CurrentState: detail.CurrentState,
DesiredState: detail.DesiredState,
Error: detail.Error,
Node: detail.Node,
Ports: detail.Ports || app.Ports,
ID: detail.ID,
});
}
}
}
}
const runningContainers = containers.filter(
(c) =>
c.Node !== "N/A" &&
(c.DesiredState === "Running" || c.CurrentState.startsWith("Running")),
);
const unscheduledServices = containers.filter((c) => c.Node === "N/A");
const downNodes = (nodes ?? []).filter(
(n: SwarmNode) => n.Status !== "Ready" || n.Availability !== "Active",
);
const isMultiNode = (nodes?.length ?? 0) > 1;
const nodeStatusMap = new Map<string, SwarmNode>();
if (nodes) {
for (const node of nodes) {
nodeStatusMap.set(node.Hostname, node);
}
}
const statsMap = new Map<string, ContainerStat>();
if (stats) {
for (const stat of stats) {
statsMap.set(stat.Name, stat);
}
}
const findStatsForContainer = (
taskName: string,
): ContainerStat | undefined => {
for (const [containerName, stat] of statsMap) {
if (containerName.startsWith(`${taskName}.`)) {
return stat;
}
}
return undefined;
};
useEffect(() => {
if (runningContainers.length > 0 && expandedNodes.size === 0) {
const nodeNames = new Set<string>();
for (const c of runningContainers) {
if (c.Node) {
nodeNames.add(c.Node);
}
}
setExpandedNodes(nodeNames);
}
}, [runningContainers.length]);
const toggleNode = (nodeName: string) => {
setExpandedNodes((prev: Set<string>) => {
const next = new Set(prev);
if (next.has(nodeName)) {
next.delete(nodeName);
} else {
next.add(nodeName);
}
return next;
});
};
const handleRefresh = () => {
refetchApps();
refetchDetails();
};
// Build node groups
const nodeMap = new Map<string, ContainerInfo[]>();
for (const c of runningContainers) {
const nodeName = c.Node || "Unknown";
if (!nodeMap.has(nodeName)) {
nodeMap.set(nodeName, []);
}
nodeMap.get(nodeName)!.push(c);
}
const nodeGroups = [];
for (const [nodeName, nodeContainers] of nodeMap) {
nodeGroups.push({
nodeName,
containers: nodeContainers,
nodeStatus: nodeStatusMap.get(nodeName),
});
}
nodeGroups.sort((a, b) => a.nodeName.localeCompare(b.nodeName));
if (isLoading) {
return (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[40vh]">
<span>Loading containers...</span>
<Loader2 className="animate-spin size-4" />
</div>
);
}
if (nodesError) {
return (
<SwarmNotAvailable
errorMessage={nodesErrorDetail?.message}
onRetry={() => refetchNodes()}
/>
);
}
if (!nodesError && nodes === undefined) {
return (
<SwarmNotAvailable
errorMessage="Docker Swarm may not be initialized — docker node ls returned no data."
onRetry={() => refetchNodes()}
/>
);
}
const isRealAppsError =
appsError && !appsErrorDetail?.message?.includes("data is undefined");
if (isRealAppsError) {
return (
<ServicesError
errorMessage={appsErrorDetail?.message}
onRetry={() => refetchApps()}
/>
);
}
if (!nodeApps || nodeApps.length === 0) {
return (
<NoServices
nodeCount={nodes?.length ?? 0}
onRefresh={() => refetchApps()}
/>
);
}
if (runningContainers.length === 0) {
return (
<NoRunningContainers
serviceCount={nodeApps.length}
containers={containers}
onRefresh={handleRefresh}
/>
);
}
return (
<div className="flex flex-col gap-4">
<header className="flex items-center flex-wrap gap-4 justify-between">
<div className="space-y-1">
<CardTitle className="text-xl flex flex-row gap-2">
<Container className="size-6 text-muted-foreground self-center" />
Container Breakdown by Node
</CardTitle>
<p className="text-sm text-muted-foreground">
Showing containers across {nodes?.length ?? 0} swarm node(s)
{statsLoading ? "" : " (metrics refresh every 5s)"}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</header>
<SummaryCards
nodeCount={nodes?.length ?? 0}
downNodeCount={downNodes.length}
serviceCount={nodeApps?.length ?? 0}
unscheduledCount={unscheduledServices.length}
runningContainerCount={runningContainers.length}
/>
{downNodes.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{downNodes.length} Node(s) Unavailable</AlertTitle>
<AlertDescription>
<p className="mb-2">
The following nodes are not ready or have been drained. Containers
scheduled on these nodes may not be running.
</p>
<ul className="list-disc list-inside space-y-1 text-xs">
{downNodes.map((node: SwarmNode) => (
<li key={node.ID}>
<strong>{node.Hostname}</strong> &mdash; Status: {node.Status}
, Availability: {node.Availability}
{node.ManagerStatus && ` (${node.ManagerStatus})`}
</li>
))}
</ul>
<p className="mt-2 text-xs">
Manage nodes in{" "}
<Link
href="/dashboard/settings/cluster"
className="underline underline-offset-4"
>
Cluster Settings
</Link>
</p>
</AlertDescription>
</Alert>
)}
{isMultiNode && (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Multi-Node Metrics Note</AlertTitle>
<AlertDescription>
CPU, memory, and I/O metrics are collected from the manager node via{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
docker stats
</code>
. Containers running on worker nodes will show &ldquo;--&rdquo; for
metrics.
</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4">
{nodeGroups.map((group) => (
<NodeSection
key={group.nodeName}
group={group}
isExpanded={expandedNodes.has(group.nodeName)}
onToggleNode={toggleNode}
findStatsForContainer={findStatsForContainer}
/>
))}
</div>
{unscheduledServices.length > 0 && (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>
{unscheduledServices.length} Service(s) With No Running Tasks
</AlertTitle>
<AlertDescription>
<p className="mb-2">
These services exist in the swarm but have no running containers.
They may be scaled to 0 replicas or failing to start.
</p>
<ul className="list-disc list-inside space-y-1 text-xs">
{unscheduledServices.map((svc) => (
<li key={svc.ID}>
<strong>{svc.Name}</strong>
{svc.Error && svc.Error.trim() !== "" && (
<span className="text-destructive ml-1">
&mdash; {svc.Error}
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { Container, Cpu, Server } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface SummaryCardsProps {
nodeCount: number;
downNodeCount: number;
serviceCount: number;
unscheduledCount: number;
runningContainerCount: number;
}
export const SummaryCards = ({
nodeCount,
downNodeCount,
serviceCount,
unscheduledCount,
runningContainerCount,
}: SummaryCardsProps) => (
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Swarm Nodes</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{nodeCount}</div>
{downNodeCount > 0 && (
<p className="text-xs text-destructive mt-1">
{downNodeCount} node(s) down or drained
</p>
)}
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Services</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Cpu className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{serviceCount}</div>
{unscheduledCount > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{unscheduledCount} with no running tasks
</p>
)}
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Running Containers
</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Container className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{runningContainerCount}</div>
</CardContent>
</Card>
</div>
);

View File

@@ -0,0 +1,35 @@
export interface ContainerStat {
BlockIO: string;
CPUPerc: string;
Container: string;
ID: string;
MemPerc: string;
MemUsage: string;
Name: string;
NetIO: string;
}
export interface ContainerInfo {
Name: string;
Image: string;
Node: string;
CurrentState: string;
DesiredState: string;
Ports: string;
Error: string;
ID: string;
}
export interface SwarmNode {
ID: string;
Hostname: string;
Status: string;
Availability: string;
ManagerStatus: string;
}
export interface NodeGroup {
nodeName: string;
containers: ContainerInfo[];
nodeStatus?: SwarmNode;
}

View File

@@ -0,0 +1,31 @@
/** Round a value+unit string like "2.711MiB" → "2.7 MiB" */
export const formatSizeValue = (raw: string): string => {
const match = raw.match(/^([\d.]+)\s*([A-Za-z]+)$/);
if (!match?.[1] || !match[2]) return raw;
const num = Number.parseFloat(match[1]);
const unit = match[2];
if (Number.isNaN(num)) return raw;
const rounded = num >= 1 ? num.toFixed(1) : num.toFixed(2);
return `${rounded} ${unit}`;
};
/** Format "2.711MiB / 7.609GiB" → "2.7 MiB / 7.6 GiB" */
export const formatMemUsage = (raw: string): string => {
const [left, right] = raw.split("/").map((s) => s.trim());
if (!left || !right) return raw;
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
};
/** Format "978B / 252B" → "978 B / 252 B" */
export const formatIOValue = (raw: string): string => {
const [left, right] = raw.split("/").map((s) => s.trim());
if (!left || !right) return raw;
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
};
/** Format "0.00%" → "0.0%", "12.345%" → "12.3%" */
export const formatCpu = (raw: string): string => {
const num = Number.parseFloat(raw.replace("%", ""));
if (Number.isNaN(num)) return raw;
return `${num.toFixed(1)}%`;
};

View File

@@ -19,6 +19,7 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
House,
Key,
KeyRound,
Loader2,
@@ -148,6 +149,12 @@ type Menu = {
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Home",
url: "/dashboard/home",
icon: House,
},
{
isSingle: true,
title: "Projects",

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