Compare commits

...

131 Commits

Author SHA1 Message Date
Mauricio Siu
0bdaa81263 fix(backups): replace findAdmin with db query to fetch admin by role for cron job initialization 2025-04-02 07:10:19 -06:00
Mauricio Siu
baf36b6fb6 Merge pull request #1608 from Dokploy/fix/move-cron-to-after-migration
Fix/move cron to after migration
2025-04-02 06:58:38 -06:00
Mauricio Siu
d632e83799 Update dokploy version to v0.21.2 in package.json 2025-04-02 06:53:41 -06:00
Mauricio Siu
6f52edd845 fix(backups): ensure initCronJobs is awaited before migration to improve backup reliability 2025-04-02 06:53:32 -06:00
Mauricio Siu
9d0f5bc8cd Update package.json 2025-04-02 00:31:55 -06:00
Mauricio Siu
3dc558c260 Merge pull request #1599 from vicke4/backup-cron-fix-attempt
fix(backups): awaiting initCronJobs call in an attempt to fix backups cron
2025-04-02 00:31:20 -06:00
vicke4
180aa34140 fix(backups): awaiting initcronjobs in an attempt to fix backups cron 2025-04-02 11:07:48 +05:30
Mauricio Siu
ffc85b04a8 Merge pull request #1572 from thebadking/refactor-show-build-form-and-prettier
build form optimization
2025-03-30 03:36:19 -06:00
Mauricio Siu
dbcfc702d4 Merge branch 'canary' into refactor-show-build-form-and-prettier 2025-03-30 03:31:49 -06:00
Mauricio Siu
7805efc738 Merge pull request #1584 from Dokploy/1294-duplicate-project-andor-services-accross-projects
1294 duplicate project andor services accross projects
2025-03-30 02:50:09 -06:00
Mauricio Siu
3910e22412 Refactor Duplicate Project component and integrate with project detail page
- Updated the DuplicateProject component to accept projectId and selectedServiceIds as props, enhancing its flexibility.
- Removed unnecessary state management for service selection within the component.
- Integrated the DuplicateProject component directly into the project detail page, allowing for easier access to duplication functionality.
- Improved the user interface by utilizing DialogTrigger for initiating the duplication process and displaying selected services more clearly.
2025-03-30 02:42:35 -06:00
Mauricio Siu
2f16034cb0 Add Duplicate Project functionality
- Introduced a new component for duplicating projects, allowing users to create a new project with the same configuration as an existing one.
- Implemented a mutation in the project router to handle project duplication, including optional service duplication.
- Updated the project detail page to include a dropdown menu for initiating the duplication process.
- Enhanced the API to validate and process the duplication request, ensuring proper handling of services associated with the project.
2025-03-30 02:38:53 -06:00
Mauricio Siu
d4925dd2b7 Update dokploy version to v0.21.0 in package.json 2025-03-30 01:59:43 -06:00
Mauricio Siu
5aba6c79a0 Merge pull request #1582 from Dokploy/fix/allow-false-values-in-env
Fix/allow false values in env
2025-03-30 01:40:06 -06:00
Mauricio Siu
84f5627471 Enhance environment variable handling in processTemplate: support boolean and number types in env configuration, with tests for both array and object formats. 2025-03-30 01:29:23 -06:00
Mauricio Siu
4eaf8fee0f Add toml package and update configuration parsing in Dokploy
- Added toml package version 3.0.0 to package.json files in both apps/dokploy and packages/server.
- Replaced js-yaml load function with toml parse function for configuration parsing in compose.ts and github.ts files, ensuring compatibility with TOML format.
- Updated pnpm-lock.yaml to reflect the new toml dependency.
2025-03-30 01:12:27 -06:00
Mauricio Siu
adee87b6da Enhance volume handling in Docker Compose: update addSuffixToVolumesInServices to correctly manage volume paths with subdirectories and improve test coverage for suffix changes in volume names. 2025-03-30 00:32:09 -06:00
Mauricio Siu
e5e987fcf9 Merge branch 'canary' into fix/allow-false-values-in-env 2025-03-29 23:51:48 -06:00
Mauricio Siu
d0a6373dcc Update Dockerfile to include 'zip' package in production environment for enhanced file handling capabilities. 2025-03-29 23:49:45 -06:00
Mauricio Siu
8ed44066ad Update Card component in server settings page: adjust width class for better responsiveness and layout consistency. 2025-03-29 23:38:28 -06:00
Mauricio Siu
befe2193a7 Merge pull request #1581 from Dokploy/709-back-ups-for-dokploy
709 back ups for dokploy
2025-03-29 23:27:29 -06:00
Mauricio Siu
f20c73cdee Refactor temporary directory creation in web server backup: replace static path with dynamic temp directory using mkdtemp for improved file management and isolation during backup operations. 2025-03-29 23:27:13 -06:00
Mauricio Siu
16bfc09202 Refactor serverId assignment in ShowBackups component: update logic to check for serverId presence in postgres object, improving code clarity and consistency. 2025-03-29 20:24:39 -06:00
Mauricio Siu
d54a61b2a4 Remove commented-out backup commands from restoreWebServerBackup function for cleaner code and improved readability. 2025-03-29 20:14:55 -06:00
Mauricio Siu
60c09a6434 Implement cloud check in backup and restore functions: add IS_CLOUD condition to skip operations for cloud environments in runWebServerBackup and restoreWebServerBackup functions. 2025-03-29 20:14:36 -06:00
Mauricio Siu
5361e9074f Refactor backup scheduling: consolidate backup handling for multiple database types into a single loop, enhance error logging, and integrate web server backup functionality. 2025-03-29 20:12:55 -06:00
Mauricio Siu
13d4dea504 Enhance ShowBackups component: add Database icon to title for improved UI clarity and wrap ShowBackups in a Card component for better layout in server settings page. 2025-03-29 19:53:25 -06:00
Mauricio Siu
ffc2d593e4 Refactor restoreWebServerBackup function: implement temporary directory creation outside BASE_PATH, streamline filesystem restoration process, and enhance logging for database restore operations. 2025-03-29 19:47:02 -06:00
Mauricio Siu
297439a348 Update restore-backup component and backup router for web server support: set default database name based on type, disable input for web server, and streamline backup restoration process with improved logging and error handling. 2025-03-29 19:27:05 -06:00
Mauricio Siu
ff3e067866 Implement web server backup and restore functionality: add new backup and restore methods for web servers, including S3 integration and improved logging. Refactor existing backup process to support web server type and streamline temporary file management. 2025-03-29 18:43:35 -06:00
Mauricio Siu
f008a45bf2 Enhance backup process: implement temporary directory structure for backups, add filesystem backup, and create zip file of database and filesystem contents. Update logging for better visibility during backup operations. 2025-03-29 18:00:13 -06:00
Mauricio Siu
50c8503cf9 Enhance backup functionality: add support for 'web-server' database type in backup components, update related schemas, and implement new backup procedures. Introduce userId reference in backups schema and adjust UI components to accommodate the new type. 2025-03-29 17:53:57 -06:00
Mauricio Siu
930a03de60 Merge pull request #1523 from jrparks/feat/add-gitea-repo
Feat/add gitea repo
2025-03-29 15:43:55 -06:00
autofix-ci[bot]
2d3d86e823 [autofix.ci] apply automated fixes 2025-03-29 21:18:08 +00:00
André Ferreira
7bab166e1b increased type safety 2025-03-29 21:17:32 +00:00
Mauricio Siu
7a6e1dbc1b Refactor SecurityAudit component: remove unused root login security check and related UI elements to streamline the code and improve readability. 2025-03-29 15:04:33 -06:00
Mauricio Siu
17a859d26d Refactor ShowConvertedCompose component: simplify conditional rendering of the compose file display by removing unnecessary null check and ensuring consistent layout. 2025-03-29 15:01:55 -06:00
Mauricio Siu
d793c6a2ec Add Gitea repository link in SaveGiteaProvider: integrate dynamic repository URL display and enhance user experience with a view link for existing repositories. 2025-03-29 15:00:23 -06:00
Mauricio Siu
3adb9d54f4 Refactor ShowProviderFormCompose component: streamline provider rendering logic and ensure Gitea integration is fully functional with updated tab and content handling. 2025-03-29 14:57:36 -06:00
Mauricio Siu
7144adbf0c Add migration for '0081_lovely_mentallo': remove gitea_username column and update journal with new version 2025-03-29 14:47:51 -06:00
Mauricio Siu
55328468d1 Refactor Gitea integration: remove giteaProjectId references and update related schemas. Add new fields for gitea repository details in application tests and components. 2025-03-29 14:44:33 -06:00
Mauricio Siu
6968cb6930 Merge pull request #1465 from zaaakher/docs/guides
docs: update `CONTRIBUTING.md` and add `GUIDES.md`
2025-03-29 14:28:24 -06:00
Mauricio Siu
a431e4c58e Update CONTRIBUTING.md 2025-03-29 14:28:11 -06:00
Mauricio Siu
fe967239b4 Merge branch 'canary' into feat/add-gitea-repo 2025-03-29 13:47:18 -06:00
Mauricio Siu
c5b4b85470 Merge pull request #1578 from Dokploy/fix/biome-lint
chore(workflow): add Biome code formatting workflow for canary branch
2025-03-29 13:41:49 -06:00
Mauricio Siu
b1ef9d25b1 chore(workflow): add autofix.ci workflow for automatic code formatting on canary branch 2025-03-29 13:41:05 -06:00
Mauricio Siu
74f7c51530 chore(workflow): add autofix.ci workflow for automatic code formatting with Biome 2025-03-29 13:39:57 -06:00
Mauricio Siu
4ba2b9fe8d chore(workflow): add new Biome formatting workflow for canary branch 2025-03-29 13:38:55 -06:00
Mauricio Siu
413eda50f4 chore(workflow): simplify AutoFix action usage in Biome workflow 2025-03-29 13:37:43 -06:00
Mauricio Siu
9f09681708 chore(workflow): streamline Biome setup by replacing Node.js and pnpm steps with biomeJs action 2025-03-29 13:37:01 -06:00
Mauricio Siu
8eb174812d chore(workflow): replace manual commit step with AutoFix action for Biome formatting 2025-03-29 13:35:50 -06:00
Mauricio Siu
be77f114eb Merge ca42708035 into beadcf871a 2025-03-29 19:30:24 +00:00
Mauricio Siu
ca42708035 chore(workflow): configure git user for automated commits and enforce push 2025-03-29 13:30:18 -06:00
Mauricio Siu
8b03454a87 chore(workflow): update Biome workflow to push changes to the correct branch 2025-03-29 13:28:50 -06:00
Mauricio Siu
fa7f749f84 refactor(dokploy): standardize code formatting and improve readability across multiple components 2025-03-29 13:26:44 -06:00
Mauricio Siu
3daecd7d71 chore(workflow): update pnpm version to 9.5.0 in Biome workflow 2025-03-29 13:20:48 -06:00
Mauricio Siu
0666b5b292 chore(workflow): add pnpm setup step to Biome workflow 2025-03-29 13:20:07 -06:00
Mauricio Siu
b288ddd826 chore(workflow): add Biome code formatting workflow for canary branch 2025-03-29 13:18:50 -06:00
Mauricio Siu
beadcf871a Merge pull request #1577 from Dokploy/1568-dokploy---nextjs-affected-by-cve-2025-29927
refactor(dokploy): remove lucia-auth adapter and related authenticati…
2025-03-29 12:23:26 -06:00
Mauricio Siu
ee49dadf0b refactor(dokploy): remove lucia-auth adapter and related authentication logic; update next.js version to 15.2.4 2025-03-29 12:17:14 -06:00
Mauricio Siu
46de83a1de Merge pull request #1576 from Dokploy/1564-downloaded-ssh-keys-are-always-named-as-id_rsa-keys
refactor(ssh-keys): simplify downloadKey function and filename genera…
2025-03-29 12:13:38 -06:00
Mauricio Siu
fee5024b7d refactor(ssh-keys): simplify downloadKey function and filename generation logic 2025-03-29 12:13:22 -06:00
André Ferreira
1f28a21835 remove prettier 2025-03-28 19:21:39 +00:00
André Ferreira
0114b371f5 prettier and build form optimization 2025-03-28 17:31:53 +00:00
Jason Parks
66d6cb5710 Fixed compose bug and formatted. Updated the refresh token to check the expired time. 2025-03-27 15:27:53 -06:00
Jason Parks
5927c7c3c5 Added back in compose alert block 2025-03-27 13:26:24 -06:00
Jason Parks
84afcf0de5 Removed apps/dokploy/templates/infisical/index.ts 2025-03-27 13:12:02 -06:00
Mauricio Siu
e3527f7d69 fix(processors): ensure environment variable processing handles non-string values correctly 2025-03-26 01:48:50 -06:00
Jason Parks
39f4a35cc8 fix: remove giteaPathNamespace 2025-03-24 01:53:00 -06:00
Mauricio Siu
e0433e9f7b Merge pull request #1554 from Dokploy/1061-custom-docker-service-hostname
1061 custom docker service hostname
2025-03-23 23:58:28 -06:00
Mauricio Siu
d29ff881fc fix(redis-connection): update Redis host configuration to use environment variable for production 2025-03-23 23:43:38 -06:00
Mauricio Siu
568c3a1d06 chore(workflow): update branches for Dokploy Docker build to use custom hostname 2025-03-23 23:38:55 -06:00
Mauricio Siu
e9fd280fa2 refactor(server): comment out initialization of Postgres, Traefik, and Redis for testing purposes 2025-03-23 23:28:53 -06:00
Jason Parks
5d5913f39d fix: resolve aria-hidden accessibility error in dialog component
Remove custom onOpenAutoFocus handler that was preventing proper focus management in the EditGiteaProvider dialog. This fixes the 'Blocked aria-hidden on an element because its descendant retained focus' error by allowing the dialog component to handle focus naturally.
2025-03-23 16:13:16 -06:00
Jason Parks
f04c8a36af Fix Gitea repository integration and preview compose display 2025-03-23 15:35:22 -06:00
Jason Parks
d5137d5d3a fix: handle repository id null case consistently 2025-03-23 14:04:30 -06:00
Mauricio Siu
9535fca28f Merge pull request #1540 from vicke4/backup-deletion-fix
fix(backups): auto deletion of backups
2025-03-23 04:31:37 -06:00
Mauricio Siu
dd62d603e0 Merge pull request #1550 from Dokploy/1543-preview-docker-compose-button-null-when-git-is-provider
feat(dashboard): add informational alert for docker-compose preview r…
2025-03-23 04:30:58 -06:00
Mauricio Siu
8d227e2a2c feat(dashboard): add informational alert for docker-compose preview requirements 2025-03-23 04:30:00 -06:00
Mauricio Siu
048c8ffc11 feat: Add Docker image push notification in commit message extraction
- Enhanced the extractCommitMessage function to include a specific message format for Docker image pushes when the user-agent indicates a Go-http-client. This improves clarity in deployment notifications by providing detailed information about the repository and the pusher.
2025-03-23 04:12:08 -06:00
Mauricio Siu
b59597630c refactor: Remove commented-out Gitea requirements check in Git providers component
- Eliminated unnecessary commented code related to Gitea access token checks in the ShowGitProviders component to enhance code clarity and maintainability.
2025-03-23 04:10:00 -06:00
Mauricio Siu
707463f973 refactor: Streamline Docker service management and error handling
- Removed unnecessary console logging in the mechanizeDockerContainer function to enhance code clarity.
- Simplified error handling by directly creating a new service if the existing one is not found, improving the deployment logic.
- Updated the buildNixpacks function to ensure container cleanup is attempted without additional error handling, streamlining the process.
2025-03-23 04:09:06 -06:00
Mauricio Siu
4b3e0805a4 chore: Update dependencies and clean up code formatting
- Downgraded '@trpc/server' version in package.json and pnpm-lock.yaml to match other TRPC packages.
- Removed unused dependencies 'node-fetch', 'data-uri-to-buffer', 'fetch-blob', and 'formdata-polyfill' from pnpm-lock.yaml and package.json.
- Improved code formatting in various components for better readability, including adjustments in 'edit-gitea-provider.tsx' and 'security-audit.tsx'.
- Refactored imports in 'auth.ts' for better organization and clarity.
2025-03-23 04:06:35 -06:00
Mauricio Siu
148c30f604 refactor: Remove unnecessary logging in Gitea repository fetching
- Eliminated console logging of repositories in the Gitea API router to streamline the code and improve performance.
2025-03-23 03:57:37 -06:00
Mauricio Siu
95f79f2afb feat: Enhance deployment logic for multiple Git providers
- Added support for handling commit normalization across GitHub, GitLab, and Gitea in the deployment API.
- Implemented a new utility function to determine the provider based on request headers.
- Improved deployment path validation to ensure consistency across different source types.
- Cleaned up the code by removing redundant checks and enhancing readability.
2025-03-23 03:55:11 -06:00
Mauricio Siu
a067abd3e4 refactor: Enhance Gitea repository handling and clean up token refresh logic
- Updated Gitea repository cloning to use the remote variant for better consistency.
- Removed unnecessary logging and comments in the token refresh function to streamline the code.
- Improved type handling in the cloneGiteaRepository function for better clarity.
2025-03-23 03:35:02 -06:00
Mauricio Siu
9359ee7a04 refactor: Update Gitea provider components and API handling
- Adjusted GiteaProviderSchema to ensure watchPaths are correctly initialized and validated.
- Refactored SaveGiteaProvider and SaveGiteaProviderCompose components for improved state management and UI consistency.
- Simplified API router methods for Gitea, enhancing readability and error handling.
- Updated database schema and service functions for better clarity and maintainability.
- Removed unnecessary comments and improved logging for better debugging.
2025-03-23 03:27:19 -06:00
Jason Parks
fc7eff94b6 fix: Security Audit SSH Errors #1377
- Fixed SSH key authentication detection in server-audit.ts
- Added proper handling for prohibit-password and other secure root login options
- Fixed typos in security audit UI labels
- Improved error handling with optional chaining
2025-03-22 14:26:40 -06:00
Jason Parks
ff3d444b89 fix: prevent form dropdown flicker in Gitea provider modal
Prevents the brief appearance of dropdown options when opening the Edit Gitea Provider modal by:
- Adding onOpenAutoFocus event handler to prevent automatic focus
- Setting autoFocus={false} on the first input field
- Simplifying component state management

This improves the UI experience by eliminating visual artifacts when the dialog opens.
2025-03-22 13:30:47 -06:00
Jason Parks
530ad31aaa Simplify Gitea authorization flow with shared utilities 2025-03-20 16:48:59 -06:00
vicke4
68d0a48843 fix(backups): auto deletion of backups 2025-03-21 01:36:11 +05:30
Jason Parks
a4e4d1c467 Fix Gitea watch branch validation logic 2025-03-20 13:50:55 -06:00
Jason Parks
56d8defebe Added watchlist paths for Gitea and some minor typescript fixes. 2025-03-19 16:48:51 -06:00
Jason Parks
997e755b6f Merge remote-tracking branch 'upstream/canary' into feat/add-gitea-repo 2025-03-19 14:06:29 -06:00
Jason Parks
852011dde8 Merge branch 'feat/add-gitea-repo' of https://github.com/jrparks/dokploy into feat/add-gitea-repo 2025-03-19 11:32:29 -06:00
Jason Parks
d7ef201adb Refactor Gitea integration and update related components 2025-03-19 11:31:08 -06:00
Mauricio Siu
91183056f0 Merge pull request #1534 from Dokploy/feat/enable-swarm-overview
Feat/enable swarm overview
2025-03-19 00:52:22 -06:00
Mauricio Siu
03bd4398d0 chore(package): bump version to v0.20.8 2025-03-19 00:51:49 -06:00
Mauricio Siu
8c260eff72 feat(cluster): enhance AddNode and ShowNodes components for better user guidance and functionality
- Added an AlertBlock in AddNode to inform users about architecture compatibility when adding nodes.
- Updated ShowNodes to correctly handle node deletion actions based on ManagerStatus.
- Refactored cluster API to remove cloud-specific checks and improve command execution for remote servers.
2025-03-19 00:51:27 -06:00
Mauricio Siu
4eef65f1b7 fix(git): update Gitea provider instructions and improve Git providers display
- Enhanced the Gitea provider setup instructions by specifying the navigation path for creating a new OAuth2 application.
- Added a blank line for better readability in the Git providers display component.
2025-03-18 21:50:01 -06:00
Mauricio Siu
a7535c6862 Merge branch 'canary' into feat/add-gitea-repo 2025-03-18 21:42:42 -06:00
Mauricio Siu
6e28196b0e chore(package): bump version to v0.20.7 2025-03-18 21:36:39 -06:00
Mauricio Siu
18bacae175 Merge pull request #1507 from nb5p/fix-alpine-linux-compatibility
fix(server-setup): resolve Alpine Linux compatibility issues
2025-03-18 21:35:43 -06:00
Mauricio Siu
f2be5a378e Merge pull request #1522 from ensarkurrt/canary
fix(ui): Improve Numeric Input Handling in Swarm Cluster Settings, Traefik Port Mappings, and Email Notifications
2025-03-18 21:27:20 -06:00
Mauricio Siu
aef24296b9 Merge pull request #1531 from Dokploy/fix/loader-swarm
Fix/loader swarm
2025-03-18 21:18:17 -06:00
Mauricio Siu
7123b9b109 feat(cluster): add error handling in AddManager and AddWorker components
- Integrated error handling in AddManager and AddWorker components to display error messages using AlertBlock when data fetching fails.
- Updated API query hooks to include error and isError states for improved user feedback during data operations.
2025-03-18 21:17:11 -06:00
Mauricio Siu
891dc840f5 feat(cluster): enhance node management UI with loading indicators and improved tab content
- Added loading indicators in AddManager and AddWorker components to enhance user experience during data fetching.
- Updated AddNode component to include overflow handling for tab content.
- Renamed "Show Nodes" to "Show Swarm Nodes" in ShowNodesModal for clarity.
2025-03-18 21:11:50 -06:00
Zakher Masri
bc78100613 remove redis part 2025-03-18 12:02:03 +03:00
Mauricio Siu
ff22404b3b feat(gitea): add Gitea integration with database schema updates
- Introduced Gitea support by adding necessary database tables and columns.
- Updated enum types to include 'gitea' for source and git provider types.
- Established foreign key relationships between Gitea and application/compose tables.
- Removed obsolete Gitea-related SQL files and updated journal entries for clarity.
2025-03-18 01:16:38 -06:00
Mauricio Siu
17330ca71a refactor: standardize import statements and improve code structure across multiple components
- Updated import statements to maintain consistency and clarity.
- Refactored components to enhance readability and organization.
- Ensured proper usage of type imports and removed unnecessary comments.
- Improved user feedback mechanisms in forms and alerts for better user experience.
2025-03-18 00:52:34 -06:00
Mauricio Siu
2898a5e575 Merge branch 'canary' into feat/add-gitea-repo 2025-03-18 00:50:27 -06:00
Mauricio Siu
172694be30 Merge pull request #1530 from Dokploy/feat/add-date-to-restore-item
feat(backup): enhance RestoreBackup component and API to include serv…
2025-03-18 00:49:02 -06:00
Mauricio Siu
ea6cfc9d29 feat(backup): enhance RestoreBackup component and API to include serverId
- Added serverId prop to RestoreBackup component for better context during backup restoration.
- Updated ShowBackups component to pass serverId from the Postgres object.
- Modified backup API to handle serverId, allowing remote execution of backup commands when specified.
- Improved file display in RestoreBackup for better user experience.
2025-03-18 00:47:50 -06:00
Mauricio Siu
4fa5e10789 chore(package): bump version to v0.20.6 2025-03-18 00:18:39 -06:00
Mauricio Siu
cb7fbb777c Merge pull request #1528 from Dokploy/1524-getting-502-bad-gateway
1524 getting 502 bad gateway
2025-03-18 00:15:12 -06:00
Mauricio Siu
6a388fe370 feat(domain): add validation for traefik.me domain IP address requirement
- Implemented a check to ensure an IP address is set for traefik.me domains in the AddDomain and AddDomainCompose components.
- Integrated a new API query to determine if traefik.me domains can be generated based on the server's IP address.
- Added user feedback through alert messages when the IP address is not configured.
2025-03-18 00:13:55 -06:00
Mauricio Siu
0722182650 feat(auth): implement user creation validation and IP update logic
- Added validation for user creation to check for existing admin presence and validate x-dokploy-token.
- Integrated public IP retrieval for user updates when not in cloud environment.
- Enhanced error handling with APIError for better feedback during user creation process.
2025-03-17 23:59:39 -06:00
Mauricio Siu
5e1095d199 Merge pull request #1526 from Dokploy/fix/mongo-db-button-deploy
refactor: improve code formatting and structure in ShowGeneralMongo c…
2025-03-17 23:18:18 -06:00
Mauricio Siu
c80a31e8c4 refactor: improve code formatting and structure in ShowGeneralMongo component
- Standardized indentation and formatting for better readability.
- Enhanced tooltip integration within button elements for improved user experience.
- Maintained functionality for deploying, reloading, starting, and stopping MongoDB instances while ensuring consistent code style.
2025-03-17 23:16:29 -06:00
Jason Parks
fac8ea7a30 Merge branch 'canary' into feat/add-gitea-repo 2025-03-17 15:25:02 -06:00
Jason Parks
9a11d0db97 feat(gitea): add Gitea repository support 2025-03-17 15:17:35 -06:00
Ensar Kurt
3cdf4c426c revert commit from #1513 2025-03-18 00:05:59 +03:00
Ensar Kurt
7cb184dc97 email notification port, last digit staying error fix 2025-03-17 23:48:17 +03:00
Ensar Kurt
fe57333f84 manage port inputs, default zero fix 2025-03-17 23:47:54 +03:00
Ensar Kurt
04fd77c3a9 replicas input cannot be zero and empty 2025-03-17 23:42:09 +03:00
Mauricio Siu
7c17cfb5c7 refactor: improve button structure and tooltip integration across dashboard components
- Refactored button components in the dashboard to enhance structure and readability.
- Integrated tooltips directly within button elements for better user experience.
- Updated tooltip descriptions for clarity across various database actions (Deploy, Reload, Start, Stop) for Redis, MySQL, PostgreSQL, and MariaDB.
- Ensured consistent formatting and improved code maintainability.
2025-03-16 20:52:57 -06:00
Jason Parks
cf28640188 Merge branch 'Dokploy:canary' into canary 2025-03-16 13:13:41 -06:00
Jason Parks
ea39b152f4 fix: resolved merge conflicts with fork/canary 2025-03-16 03:02:15 -06:00
Jason Parks
027406547e feat(gitea): Added Gitea Repo Integration 2025-03-16 02:11:48 -06:00
nb5p
2974a8183e fix(server-setup): resolve Alpine Linux compatibility issues with setup scripts
Resolves #1482
2025-03-16 15:37:28 +08:00
Zakher Masri
ac0922d742 docs: update CONTRIBUTING.md and add GUIDES.md 2025-03-11 14:38:37 +03:00
165 changed files with 34553 additions and 3621 deletions

View File

@@ -2,7 +2,7 @@ name: Dokploy Docker Build
on: on:
push: push:
branches: [main, canary, "feat/better-auth-2"] branches: [main, canary, "1061-custom-docker-service-hostname"]
env: env:
IMAGE_NAME: dokploy/dokploy IMAGE_NAME: dokploy/dokploy

22
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: autofix.ci
on:
push:
branches: [canary]
pull_request:
branches: [canary]
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format . --write
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@@ -61,9 +61,9 @@ pnpm install
cp apps/dokploy/.env.example apps/dokploy/.env cp apps/dokploy/.env.example apps/dokploy/.env
``` ```
## Development ## Requirements
Is required to have **Docker** installed on your machine. - [Docker](/GUIDES.md#docker)
### Setup ### Setup

View File

@@ -29,7 +29,7 @@ WORKDIR /app
# Set production # Set production
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files # Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next COPY --from=build /prod/dokploy/.next ./.next

49
GUIDES.md Normal file
View File

@@ -0,0 +1,49 @@
# Docker
Here's how to install docker on different operating systems:
## macOS
1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop)
2. Download the Docker Desktop installer
3. Double-click the downloaded `.dmg` file
4. Drag Docker to your Applications folder
5. Open Docker Desktop from Applications
6. Follow the onboarding tutorial if desired
## Linux
### Ubuntu
```bash
# Update package index
sudo apt-get update
# Install prerequisites
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set up stable repository
echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
```
## Windows
1. Enable WSL2 if not already enabled
2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
3. Download the installer
4. Run the installer and follow the prompts
5. Start Docker Desktop from the Start menu

View File

@@ -1006,7 +1006,7 @@ services:
volumes: volumes:
db-config-testhash: db-config-testhash:
`) as ComposeSpecification; `);
test("Expect to change the suffix in all the possible places (4 Try)", () => { test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification; const composeData = load(composeFileComplex) as ComposeSpecification;
@@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
expect(updatedComposeData).toEqual(expectedDockerComposeExample1); expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
}); });
const composeFileBackrest = `
services:
backrest:
image: garethgeorge/backrest:v1.7.3
restart: unless-stopped
ports:
- 9898
environment:
- BACKREST_PORT=9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=\${TZ}
volumes:
- backrest/data:/data
- backrest/config:/config
- backrest/cache:/cache
- /:/userdata:ro
volumes:
backrest:
backrest-cache:
`;
const expectedDockerComposeBackrest = load(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
restart: unless-stopped
ports:
- 9898
environment:
- BACKREST_PORT=9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=\${TZ}
volumes:
- backrest-testhash/data:/data
- backrest-testhash/config:/config
- backrest-testhash/cache:/cache
- /:/userdata:ro
volumes:
backrest-testhash:
backrest-cache-testhash:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = load(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
});

View File

@@ -27,6 +27,11 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
giteaOwner: "",
giteaRepository: "",
cleanCache: false, cleanCache: false,
watchPaths: [], watchPaths: [],
applicationStatus: "done", applicationStatus: "done",

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import type { Schema } from "@dokploy/server/templates";
import type { CompleteTemplate } from "@dokploy/server/templates/processors"; import type { CompleteTemplate } from "@dokploy/server/templates/processors";
import { processTemplate } from "@dokploy/server/templates/processors"; import { processTemplate } from "@dokploy/server/templates/processors";
import type { Schema } from "@dokploy/server/templates"; import { describe, expect, it } from "vitest";
describe("processTemplate", () => { describe("processTemplate", () => {
// Mock schema for testing // Mock schema for testing
@@ -233,6 +233,49 @@ describe("processTemplate", () => {
expect(base64Value.length).toBeGreaterThanOrEqual(42); expect(base64Value.length).toBeGreaterThanOrEqual(42);
expect(base64Value.length).toBeLessThanOrEqual(44); expect(base64Value.length).toBeLessThanOrEqual(44);
}); });
it("should handle boolean values in env vars when provided as an array", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: [
"ENABLE_USER_SIGN_UP=false",
"DEBUG_MODE=true",
"SOME_NUMBER=42",
],
mounts: [],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
expect(result.envs).toContain("DEBUG_MODE=true");
expect(result.envs).toContain("SOME_NUMBER=42");
});
it("should handle boolean values in env vars when provided as an object", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {
ENABLE_USER_SIGN_UP: false,
DEBUG_MODE: true,
SOME_NUMBER: 42,
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
expect(result.envs).toContain("DEBUG_MODE=true");
expect(result.envs).toContain("SOME_NUMBER=42");
});
}); });
describe("mounts processing", () => { describe("mounts processing", () => {

View File

@@ -7,6 +7,11 @@ import { expect, test } from "vitest";
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
cleanCache: false, cleanCache: false,
applicationStatus: "done", applicationStatus: "done",
appName: "", appName: "",

View File

@@ -40,7 +40,7 @@ interface Props {
} }
const AddRedirectchema = z.object({ const AddRedirectchema = z.object({
replicas: z.number(), replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string(), registryId: z.string(),
}); });
@@ -130,9 +130,11 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
placeholder="1" placeholder="1"
{...field} {...field}
onChange={(e) => { onChange={(e) => {
field.onChange(Number(e.target.value)); const value = e.target.value;
field.onChange(value === "" ? 0 : Number(value));
}} }}
type="number" type="number"
value={field.value || ""}
/> />
</FormControl> </FormControl>

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor"; import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -32,7 +33,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const ImportSchema = z.object({ const ImportSchema = z.object({
base64: z.string(), base64: z.string(),

View File

@@ -20,7 +20,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
enum BuildType { export enum BuildType {
dockerfile = "dockerfile", dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks", heroku_buildpacks = "heroku_buildpacks",
paketo_buildpacks = "paketo_buildpacks", paketo_buildpacks = "paketo_buildpacks",
@@ -29,9 +29,18 @@ enum BuildType {
railpack = "railpack", railpack = "railpack",
} }
const buildTypeDisplayMap: Record<BuildType, string> = {
[BuildType.dockerfile]: "Dockerfile",
[BuildType.railpack]: "Railpack",
[BuildType.nixpacks]: "Nixpacks",
[BuildType.heroku_buildpacks]: "Heroku Buildpacks",
[BuildType.paketo_buildpacks]: "Paketo Buildpacks",
[BuildType.static]: "Static",
};
const mySchema = z.discriminatedUnion("buildType", [ const mySchema = z.discriminatedUnion("buildType", [
z.object({ z.object({
buildType: z.literal("dockerfile"), buildType: z.literal(BuildType.dockerfile),
dockerfile: z dockerfile: z
.string({ .string({
required_error: "Dockerfile path is required", required_error: "Dockerfile path is required",
@@ -42,39 +51,88 @@ const mySchema = z.discriminatedUnion("buildType", [
dockerBuildStage: z.string().nullable().default(""), dockerBuildStage: z.string().nullable().default(""),
}), }),
z.object({ z.object({
buildType: z.literal("heroku_buildpacks"), buildType: z.literal(BuildType.heroku_buildpacks),
herokuVersion: z.string().nullable().default(""), herokuVersion: z.string().nullable().default(""),
}), }),
z.object({ z.object({
buildType: z.literal("paketo_buildpacks"), buildType: z.literal(BuildType.paketo_buildpacks),
}), }),
z.object({ z.object({
buildType: z.literal("nixpacks"), buildType: z.literal(BuildType.nixpacks),
publishDirectory: z.string().optional(), publishDirectory: z.string().optional(),
}), }),
z.object({ z.object({
buildType: z.literal("static"), buildType: z.literal(BuildType.static),
}), }),
z.object({ z.object({
buildType: z.literal("railpack"), buildType: z.literal(BuildType.railpack),
}), }),
]); ]);
type AddTemplate = z.infer<typeof mySchema>; type AddTemplate = z.infer<typeof mySchema>;
interface Props { interface Props {
applicationId: string; applicationId: string;
} }
interface ApplicationData {
buildType: BuildType;
dockerfile?: string | null;
dockerContextPath?: string | null;
dockerBuildStage?: string | null;
herokuVersion?: string | null;
publishDirectory?: string | null;
}
function isValidBuildType(value: string): value is BuildType {
return Object.values(BuildType).includes(value as BuildType);
}
const resetData = (data: ApplicationData): AddTemplate => {
switch (data.buildType) {
case BuildType.dockerfile:
return {
buildType: BuildType.dockerfile,
dockerfile: data.dockerfile || "",
dockerContextPath: data.dockerContextPath || "",
dockerBuildStage: data.dockerBuildStage || "",
};
case BuildType.heroku_buildpacks:
return {
buildType: BuildType.heroku_buildpacks,
herokuVersion: data.herokuVersion || "",
};
case BuildType.nixpacks:
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
};
case BuildType.paketo_buildpacks:
return {
buildType: BuildType.paketo_buildpacks,
};
case BuildType.static:
return {
buildType: BuildType.static,
};
case BuildType.railpack:
return {
buildType: BuildType.railpack,
};
default:
const buildType = data.buildType as BuildType;
return {
buildType,
} as AddTemplate;
}
};
export const ShowBuildChooseForm = ({ applicationId }: Props) => { export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isLoading } =
api.application.saveBuildType.useMutation(); api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery( const { data, refetch } = api.application.one.useQuery(
{ { applicationId },
applicationId, { enabled: !!applicationId },
},
{
enabled: !!applicationId,
},
); );
const form = useForm<AddTemplate>({ const form = useForm<AddTemplate>({
@@ -85,46 +143,36 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
}); });
const buildType = form.watch("buildType"); const buildType = form.watch("buildType");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
if (data.buildType === "dockerfile") { const typedData: ApplicationData = {
form.reset({ ...data,
buildType: data.buildType, buildType: isValidBuildType(data.buildType)
...(data.buildType && { ? (data.buildType as BuildType)
dockerfile: data.dockerfile || "", : BuildType.nixpacks, // fallback
dockerContextPath: data.dockerContextPath || "", };
dockerBuildStage: data.dockerBuildStage || "",
}), form.reset(resetData(typedData));
});
} else if (data.buildType === "heroku_buildpacks") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
herokuVersion: data.herokuVersion || "",
}),
});
} else {
form.reset({
buildType: data.buildType,
publishDirectory: data.publishDirectory || undefined,
});
}
} }
}, [form.formState.isSubmitSuccessful, form.reset, data, form]); }, [data, form]);
const onSubmit = async (data: AddTemplate) => { const onSubmit = async (data: AddTemplate) => {
await mutateAsync({ await mutateAsync({
applicationId, applicationId,
buildType: data.buildType, buildType: data.buildType,
publishDirectory: publishDirectory:
data.buildType === "nixpacks" ? data.publishDirectory : null, data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null, dockerfile:
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
dockerContextPath: dockerContextPath:
data.buildType === "dockerfile" ? data.dockerContextPath : null, data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
dockerBuildStage: dockerBuildStage:
data.buildType === "dockerfile" ? data.dockerBuildStage : null, data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
herokuVersion: herokuVersion:
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null, data.buildType === BuildType.heroku_buildpacks
? data.herokuVersion
: null,
}) })
.then(async () => { .then(async () => {
toast.success("Build type saved"); toast.success("Build type saved");
@@ -160,193 +208,143 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
control={form.control} control={form.control}
name="buildType" name="buildType"
defaultValue={form.control._defaultValues.buildType} defaultValue={form.control._defaultValues.buildType}
render={({ field }) => { render={({ field }) => (
return ( <FormItem className="space-y-3">
<FormItem className="space-y-3"> <FormLabel>Build Type</FormLabel>
<FormLabel>Build Type</FormLabel> <FormControl>
<FormControl> <RadioGroup
<RadioGroup onValueChange={field.onChange}
onValueChange={field.onChange} value={field.value}
value={field.value} className="flex flex-col space-y-1"
className="flex flex-col space-y-1" >
> {Object.entries(buildTypeDisplayMap).map(
<FormItem className="flex items-center space-x-3 space-y-0"> ([value, label]) => (
<FormControl> <FormItem
<RadioGroupItem value="dockerfile" /> key={value}
</FormControl> className="flex items-center space-x-3 space-y-0"
<FormLabel className="font-normal"> >
Dockerfile <FormControl>
</FormLabel> <RadioGroupItem value={value} />
</FormItem> </FormControl>
<FormItem className="flex items-center space-x-3 space-y-0"> <FormLabel className="font-normal">
<FormControl> {label}
<RadioGroupItem value="railpack" /> {value === BuildType.railpack && (
</FormControl> <Badge className="ml-2 px-1 text-xs">New</Badge>
<FormLabel className="font-normal"> )}
Railpack{" "} </FormLabel>
<Badge className="ml-1 text-xs px-1">New</Badge> </FormItem>
</FormLabel> ),
</FormItem> )}
<FormItem className="flex items-center space-x-3 space-y-0"> </RadioGroup>
<FormControl> </FormControl>
<RadioGroupItem value="nixpacks" /> <FormMessage />
</FormControl> </FormItem>
<FormLabel className="font-normal"> )}
Nixpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="heroku_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Heroku Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="paketo_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Paketo Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="static" />
</FormControl>
<FormLabel className="font-normal">Static</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/> />
{buildType === "heroku_buildpacks" && ( {buildType === BuildType.heroku_buildpacks && (
<FormField <FormField
control={form.control} control={form.control}
name="herokuVersion" name="herokuVersion"
render={({ field }) => { render={({ field }) => (
return ( <FormItem>
<FormItem> <FormLabel>Heroku Version (Optional)</FormLabel>
<FormLabel>Heroku Version (Optional)</FormLabel> <FormControl>
<FormControl> <Input
<Input placeholder="Heroku Version (Default: 24)"
placeholder={"Heroku Version (Default: 24)"} {...field}
{...field} value={field.value ?? ""}
value={field.value ?? ""} />
/> </FormControl>
</FormControl> <FormMessage />
</FormItem>
<FormMessage /> )}
</FormItem>
);
}}
/> />
)} )}
{buildType === "dockerfile" && ( {buildType === BuildType.dockerfile && (
<> <>
<FormField <FormField
control={form.control} control={form.control}
name="dockerfile" name="dockerfile"
render={({ field }) => { render={({ field }) => (
return (
<FormItem>
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder={"Path of your docker file"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder={
"Path of your docker context default: ."
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerBuildStage"
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormDescription>
Allows you to target a specific stage in a
Multi-stage Dockerfile. If empty, Docker defaults to
build the last defined stage.
</FormDescription>
</div>
<FormControl>
<Input
placeholder={"E.g. production"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
);
}}
/>
</>
)}
{buildType === "nixpacks" && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => {
return (
<FormItem> <FormItem>
<div className="space-y-0.5"> <FormLabel>Docker File</FormLabel>
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets
should be served as a static site.
</FormDescription>
</div>
<FormControl> <FormControl>
<Input <Input
placeholder={"Publish Directory"} placeholder="Path of your docker file"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); )}
}} />
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker context (default: .)"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerBuildStage"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormDescription>
Allows you to target a specific stage in a Multi-stage
Dockerfile. If empty, Docker defaults to build the
last defined stage.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="E.g. production"
{...field}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{buildType === BuildType.nixpacks && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets should
be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="Publish Directory"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
)} )}
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">

View File

@@ -41,6 +41,7 @@ import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain"; import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react"; import { Dices } from "lucide-react";
import Link from "next/link";
import type z from "zod"; import type z from "zod";
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@@ -83,6 +84,13 @@ export const AddDomain = ({
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation(); api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domain), resolver: zodResolver(domain),
defaultValues: { defaultValues: {
@@ -186,6 +194,21 @@ export const AddDomain = ({
name="host" name="host"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{application?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel> <FormLabel>Host</FormLabel>
<div className="flex gap-2"> <div className="flex gap-2">
<FormControl> <FormControl>

View File

@@ -4,10 +4,10 @@ import { Form } from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets"; import { Secrets } from "@/components/ui/secrets";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { useEffect } from "react";
const addEnvironmentSchema = z.object({ const addEnvironmentSchema = z.object({
env: z.string(), env: z.string(),

View File

@@ -1,4 +1,6 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({ const BitbucketProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -115,7 +115,11 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Username" autoComplete="username" {...field} /> <Input
placeholder="Username"
autoComplete="username"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -130,7 +134,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" /> <Input
placeholder="Password"
autoComplete="one-time-code"
{...field}
type="password"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -26,15 +26,15 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitIcon } from "@/components/icons/data-tools-icons";
const GitProviderSchema = z.object({ const GitProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -0,0 +1,515 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface GiteaRepository {
name: string;
url: string;
id: number;
owner: {
username: string;
};
}
interface GiteaBranch {
name: string;
commit: {
id: string;
};
}
const GiteaProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
});
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
interface Props {
applicationId: string;
}
export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation();
const form = useForm<GiteaProvider>({
defaultValues: {
buildPath: "/",
repository: {
owner: "",
repo: "",
},
giteaId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GiteaProviderSchema),
});
const repository = form.watch("repository");
const giteaId = form.watch("giteaId");
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
{ giteaId },
{
enabled: !!giteaId,
},
);
const {
data: repositories,
isLoading: isLoadingRepositories,
error,
} = api.gitea.getGiteaRepositories.useQuery(
{
giteaId,
},
{
enabled: !!giteaId,
},
);
const {
data: branches,
fetchStatus,
status,
} = api.gitea.getGiteaBranches.useQuery(
{
owner: repository?.owner,
repositoryName: repository?.repo,
giteaId: giteaId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
},
);
useEffect(() => {
if (data) {
form.reset({
branch: data.giteaBranch || "",
repository: {
repo: data.giteaRepository || "",
owner: data.giteaOwner || "",
},
buildPath: data.giteaBuildPath || "/",
giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
giteaBranch: data.branch,
giteaRepository: data.repository.repo,
giteaOwner: data.repository.owner,
giteaBuildPath: data.buildPath,
giteaId: data.giteaId,
applicationId,
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Gitea provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="giteaId"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
form.setValue("repository", {
owner: "",
repo: "",
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Gitea Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
{giteaProviders?.map((giteaProvider) => (
<SelectItem
key={giteaProvider.giteaId}
value={giteaProvider.giteaId}
>
{giteaProvider.gitProvider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`${giteaUrl}/${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"
>
<GiteaIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories && repositories.length === 0 && (
<CommandEmpty>
No repositories found.
</CommandEmpty>
)}
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
" w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch: GiteaBranch) =>
branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
)}
{!repository?.owner && (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a repository
</span>
)}
<ScrollArea className="h-96">
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches?.map((branch: GiteaBranch) => (
<CommandItem
value={branch.name}
key={branch.commit.id}
onSelect={() => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
<FormMessage />
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path: string, index: number) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...field.value];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...field.value, path]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...field.value, path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button
isLoading={isSavingGiteaProvider}
type="submit"
className="w-fit"
>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -1,3 +1,5 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -34,17 +36,15 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
const GithubProviderSchema = z.object({ const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -1,4 +1,6 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -35,17 +37,15 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
const GitlabProviderSchema = z.object({ const GitlabProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -1,10 +1,12 @@
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider"; import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider"; import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
import { SaveGiteaProvider } from "@/components/dashboard/application/general/generic/save-gitea-provider";
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider"; import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
import { import {
BitbucketIcon, BitbucketIcon,
DockerIcon, DockerIcon,
GitIcon, GitIcon,
GiteaIcon,
GithubIcon, GithubIcon,
GitlabIcon, GitlabIcon,
} from "@/components/icons/data-tools-icons"; } from "@/components/icons/data-tools-icons";
@@ -18,7 +20,14 @@ import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider"; import { SaveGitlabProvider } from "./save-gitlab-provider";
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket"; type TabState =
| "github"
| "docker"
| "git"
| "drop"
| "gitlab"
| "bitbucket"
| "gitea";
interface Props { interface Props {
applicationId: string; applicationId: string;
@@ -29,6 +38,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } = const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId }); const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(application?.sourceType || "github"); const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
@@ -78,6 +88,13 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" /> <BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket Bitbucket
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="gitea"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GiteaIcon className="size-4 text-current fill-current" />
Gitea
</TabsTrigger>
<TabsTrigger <TabsTrigger
value="docker" value="docker"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -162,6 +179,26 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
</div> </div>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="gitea" className="w-full p-2">
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account
first. Please, go to{" "}
<Link
href="/dashboard/settings/git-providers"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="docker" className="w-full p-2"> <TabsContent value="docker" className="w-full p-2">
<SaveDockerProvider applicationId={applicationId} /> <SaveDockerProvider applicationId={applicationId} />
</TabsContent> </TabsContent>

View File

@@ -76,25 +76,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p> <TooltipContent sideOffset={5} className="z-[60]">
Downloads the source code and performs a complete build <p>
</p> Downloads the source code and performs a complete build
</TooltipContent> </p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
title="Reload Application" title="Reload Application"
@@ -114,23 +116,25 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={isReloading}
variant="secondary" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isReloading} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<RefreshCcw className="size-4 mr-1" /> <div className="flex items-center">
Reload <RefreshCcw className="size-4 mr-1" />
</Button> Reload
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Reload the application without rebuilding it</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Reload the application without rebuilding it</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
title="Rebuild Application" title="Rebuild Application"
@@ -149,25 +153,28 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={data?.applicationStatus === "running"}
variant="secondary" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Hammer className="size-4 mr-1" /> <div className="flex items-center">
Rebuild <Hammer className="size-4 mr-1" />
</Button> Rebuild
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p> <TooltipContent sideOffset={5} className="z-[60]">
Only rebuilds the application without downloading new code <p>
</p> Only rebuilds the application without downloading new
</TooltipContent> code
</TooltipPrimitive.Portal> </p>
</Tooltip> </TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
@@ -188,26 +195,28 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={isStarting}
variant="secondary" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isStarting} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<CheckCircle2 className="size-4 mr-1" /> <div className="flex items-center">
Start <CheckCircle2 className="size-4 mr-1" />
</Button> Start
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p> <TooltipContent sideOffset={5} className="z-[60]">
Start the application (requires a previous successful <p>
build) Start the application (requires a previous successful
</p> build)
</TooltipContent> </p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
) : ( ) : (
<DialogAction <DialogAction
@@ -226,23 +235,25 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="destructive"
<Button isLoading={isStopping}
variant="destructive" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isStopping} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Ban className="size-4 mr-1" /> <div className="flex items-center">
Stop <Ban className="size-4 mr-1" />
</Button> Stop
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Stop the currently running application</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Stop the currently running application</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider> </TooltipProvider>

View File

@@ -41,6 +41,7 @@ import {
import { domainCompose } from "@/server/db/validations/domain"; import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod"; import type z from "zod";
type Domain = z.infer<typeof domainCompose>; type Domain = z.infer<typeof domainCompose>;
@@ -102,6 +103,11 @@ export const AddDomainCompose = ({
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domainCompose), resolver: zodResolver(domainCompose),
defaultValues: { defaultValues: {
@@ -313,6 +319,21 @@ export const AddDomainCompose = ({
name="host" name="host"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{compose?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel> <FormLabel>Host</FormLabel>
<div className="flex gap-2"> <div className="flex gap-2">
<FormControl> <FormControl>

View File

@@ -55,23 +55,25 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.composeStatus === "running"}
variant="default" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.composeStatus === "running"} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads the source code and performs a complete build</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads the source code and performs a complete build</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
title="Reload Compose" title="Reload Compose"
@@ -90,23 +92,25 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={data?.composeStatus === "running"}
variant="secondary" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.composeStatus === "running"} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<RefreshCcw className="size-4 mr-1" /> <div className="flex items-center">
Reload <RefreshCcw className="size-4 mr-1" />
</Button> Reload
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Reload the compose without rebuilding it</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Reload the compose without rebuilding it</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
{data?.composeType === "docker-compose" && {data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? ( data?.composeStatus === "idle" ? (
@@ -127,25 +131,27 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={isStarting}
variant="secondary" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isStarting} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<CheckCircle2 className="size-4 mr-1" /> <div className="flex items-center">
Start <CheckCircle2 className="size-4 mr-1" />
</Button> Start
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p> <TooltipContent sideOffset={5} className="z-[60]">
Start the compose (requires a previous successful build) <p>
</p> Start the compose (requires a previous successful build)
</TooltipContent> </p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
) : ( ) : (
<DialogAction <DialogAction
@@ -164,23 +170,25 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="destructive"
<Button isLoading={isStopping}
variant="destructive" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isStopping} >
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Ban className="size-4 mr-1" /> <div className="flex items-center">
Stop <Ban className="size-4 mr-1" />
</Button> Stop
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Stop the currently running compose</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Stop the currently running compose</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider> </TooltipProvider>

View File

@@ -1,4 +1,6 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({ const BitbucketProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -1,3 +1,4 @@
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -27,13 +28,12 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { GitIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitProviderSchema = z.object({ const GitProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -0,0 +1,483 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { Repository } from "@/utils/gitea-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GiteaProviderSchema = z.object({
composePath: z.string().min(1),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
api.compose.update.useMutation();
const form = useForm<GiteaProvider>({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
owner: "",
repo: "",
},
giteaId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GiteaProviderSchema),
});
const repository = form.watch("repository");
const giteaId = form.watch("giteaId");
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
{ giteaId },
{
enabled: !!giteaId,
},
);
const {
data: repositories,
isLoading: isLoadingRepositories,
error,
} = api.gitea.getGiteaRepositories.useQuery<Repository[]>(
{
giteaId,
},
{
enabled: !!giteaId,
},
);
const {
data: branches,
fetchStatus,
status,
} = api.gitea.getGiteaBranches.useQuery(
{
owner: repository?.owner,
repositoryName: repository?.repo,
giteaId: giteaId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
},
);
useEffect(() => {
if (data) {
form.reset({
branch: data.giteaBranch || "",
repository: {
repo: data.giteaRepository || "",
owner: data.giteaOwner || "",
},
composePath: data.composePath || "./docker-compose.yml",
giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
giteaBranch: data.branch,
giteaRepository: data.repository.repo,
giteaOwner: data.repository.owner,
composePath: data.composePath,
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
} as any)
.then(async () => {
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Gitea provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="giteaId"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
form.setValue("repository", {
owner: "",
repo: "",
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Gitea Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
{giteaProviders?.map((giteaProvider) => (
<SelectItem
key={giteaProvider.giteaId}
value={giteaProvider.giteaId}
>
{giteaProvider.gitProvider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`${giteaUrl}/${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"
>
<GiteaIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
key={repo.url}
value={repo.name}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch) => branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branches..."
className="h-9"
/>
<CommandEmpty>No branches found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{branches?.map((branch) => (
<CommandItem
key={branch.name}
value={branch.name}
onSelect={() =>
form.setValue("branch", branch.name)
}
>
<span className="flex items-center gap-2">
{branch.name}
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.branch && (
<p className={cn("text-sm font-medium text-destructive")}>
Branch is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" isLoading={isSavingGiteaProvider}>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -39,12 +40,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GithubProviderSchema = z.object({ const GithubProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -1,4 +1,6 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitlabProviderSchema = z.object({ const GitlabProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -1,6 +1,7 @@
import { import {
BitbucketIcon, BitbucketIcon,
GitIcon, GitIcon,
GiteaIcon,
GithubIcon, GithubIcon,
GitlabIcon, GitlabIcon,
} from "@/components/icons/data-tools-icons"; } from "@/components/icons/data-tools-icons";
@@ -14,10 +15,11 @@ import { ComposeFileEditor } from "../compose-file-editor";
import { ShowConvertedCompose } from "../show-converted-compose"; import { ShowConvertedCompose } from "../show-converted-compose";
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose"; import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
import { SaveGitProviderCompose } from "./save-git-provider-compose"; import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
interface Props { interface Props {
composeId: string; composeId: string;
} }
@@ -27,9 +29,11 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } = const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId }); const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github"); const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
<CardHeader> <CardHeader>
@@ -54,21 +58,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
setSab(e as TabState); setSab(e as TabState);
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-5 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden"> <TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<TabsTrigger <TabsTrigger
value="github" value="github"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
> >
<GithubIcon className="size-4 text-current fill-current" /> <GithubIcon className="size-4 text-current fill-current" />
Github GitHub
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="gitlab" value="gitlab"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
> >
<GitlabIcon className="size-4 text-current fill-current" /> <GitlabIcon className="size-4 text-current fill-current" />
Gitlab GitLab
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="bitbucket" value="bitbucket"
@@ -77,7 +81,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" /> <BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket Bitbucket
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="gitea"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GiteaIcon className="size-4 text-current fill-current" /> Gitea
</TabsTrigger>
<TabsTrigger <TabsTrigger
value="git" value="git"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -89,11 +98,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
value="raw" value="raw"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
> >
<CodeIcon className="size-4 " /> <CodeIcon className="size-4" />
Raw Raw
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="github" className="w-full p-2"> <TabsContent value="github" className="w-full p-2">
{githubProviders && githubProviders?.length > 0 ? ( {githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProviderCompose composeId={composeId} /> <SaveGithubProviderCompose composeId={composeId} />
@@ -154,6 +164,26 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
</div> </div>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="gitea" className="w-full p-2">
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account
first. Please, go to{" "}
<Link
href="/dashboard/settings/git-providers"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="git" className="w-full p-2"> <TabsContent value="git" className="w-full p-2">
<SaveGitProviderCompose composeId={composeId} /> <SaveGitProviderCompose composeId={composeId} />
</TabsContent> </TabsContent>

View File

@@ -147,7 +147,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> <FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel> <FormLabel>
Enable Isolated Deployment ({data?.appName})
</FormLabel>
<FormDescription> <FormDescription>
Enable isolated deployment to the compose file. Enable isolated deployment to the compose file.
</FormDescription> </FormDescription>

View File

@@ -62,6 +62,11 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<Button <Button
variant="secondary" variant="secondary"

View File

@@ -61,7 +61,7 @@ type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props { interface Props {
databaseId: string; databaseId: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo"; databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void; refetch: () => void;
} }
@@ -85,7 +85,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
database: "", database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
prefix: "/", prefix: "/",
@@ -112,7 +112,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
? { ? {
mongoId: databaseId, mongoId: databaseId,
} }
: undefined; : databaseType === "web-server"
? {
userId: databaseId,
}
: undefined;
await createBackup({ await createBackup({
destinationId: data.destinationId, destinationId: data.destinationId,
@@ -236,7 +240,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<FormItem> <FormItem>
<FormLabel>Database</FormLabel> <FormLabel>Database</FormLabel>
<FormControl> <FormControl>
<Input placeholder={"dokploy"} {...field} /> <Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -286,16 +294,21 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<FormItem> <FormItem>
<FormLabel>Keep the latest</FormLabel> <FormLabel>Keep the latest</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} /> <Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud. Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"

View File

@@ -1,3 +1,5 @@
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -23,6 +25,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -32,22 +35,20 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { debounce } from "lodash";
import { Input } from "@/components/ui/input";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Badge } from "@/components/ui/badge";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
interface Props { interface Props {
databaseId: string; databaseId: string;
databaseType: Exclude<ServiceType, "application" | "redis">; databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
serverId?: string | null;
} }
const RestoreBackupSchema = z.object({ const RestoreBackupSchema = z.object({
@@ -76,7 +77,11 @@ const RestoreBackupSchema = z.object({
type RestoreBackup = z.infer<typeof RestoreBackupSchema>; type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
export const RestoreBackup = ({ databaseId, databaseType }: Props) => { export const RestoreBackup = ({
databaseId,
databaseType,
serverId,
}: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -86,7 +91,7 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
defaultValues: { defaultValues: {
destinationId: "", destinationId: "",
backupFile: "", backupFile: "",
databaseName: "", databaseName: databaseType === "web-server" ? "dokploy" : "",
}, },
resolver: zodResolver(RestoreBackupSchema), resolver: zodResolver(RestoreBackupSchema),
}); });
@@ -101,6 +106,7 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
{ {
destinationId: destionationId, destinationId: destionationId,
search, search,
serverId: serverId ?? "",
}, },
{ {
enabled: isOpen && !!destionationId, enabled: isOpen && !!destionationId,
@@ -304,7 +310,9 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
form.setValue("backupFile", file); form.setValue("backupFile", file);
}} }}
> >
{file} <div className="flex w-full justify-between">
<span>{file}</span>
</div>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -332,7 +340,11 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
<FormItem className=""> <FormItem className="">
<FormLabel>Database Name</FormLabel> <FormLabel>Database Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="Enter database name" /> <Input
disabled={databaseType === "web-server"}
{...field}
placeholder="Enter database name"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -14,18 +14,18 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { DatabaseBackup, Play, Trash2 } from "lucide-react"; import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup"; import { AddBackup } from "./add-backup";
import { UpdateBackup } from "./update-backup";
import { RestoreBackup } from "./restore-backup"; import { RestoreBackup } from "./restore-backup";
import { useState } from "react"; import { UpdateBackup } from "./update-backup";
interface Props { interface Props {
id: string; id: string;
type: Exclude<ServiceType, "application" | "redis">; type: Exclude<ServiceType, "application" | "redis"> | "web-server";
} }
export const ShowBackups = ({ id, type }: Props) => { export const ShowBackups = ({ id, type }: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState< const [activeManualBackup, setActiveManualBackup] = useState<
@@ -38,6 +38,7 @@ export const ShowBackups = ({ id, type }: Props) => {
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}; };
const { data } = api.destination.all.useQuery(); const { data } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type] const { data: postgres, refetch } = queryMap[type]
@@ -49,6 +50,7 @@ export const ShowBackups = ({ id, type }: Props) => {
mysql: () => api.backup.manualBackupMySql.useMutation(), mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(), mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(), mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
}; };
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[ const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
@@ -64,7 +66,10 @@ export const ShowBackups = ({ id, type }: Props) => {
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap"> <CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<CardTitle className="text-xl">Backups</CardTitle> <CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardDescription> <CardDescription>
Add backups to your database to save the data to a different Add backups to your database to save the data to a different
provider. provider.
@@ -73,8 +78,18 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && ( {postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto"> <div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
<AddBackup databaseId={id} databaseType={type} refetch={refetch} /> {type !== "web-server" && (
<RestoreBackup databaseId={id} databaseType={type} /> <AddBackup
databaseId={id}
databaseType={type}
refetch={refetch}
/>
)}
<RestoreBackup
databaseId={id}
databaseType={type}
serverId={"serverId" in postgres ? postgres.serverId : undefined}
/>
</div> </div>
)} )}
</CardHeader> </CardHeader>
@@ -108,7 +123,13 @@ export const ShowBackups = ({ id, type }: Props) => {
databaseType={type} databaseType={type}
refetch={refetch} refetch={refetch}
/> />
<RestoreBackup databaseId={id} databaseType={type} /> <RestoreBackup
databaseId={id}
databaseType={type}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
/>
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -92,7 +92,9 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: backup.enabled || false, enabled: backup.enabled || false,
prefix: backup.prefix, prefix: backup.prefix,
schedule: backup.schedule, schedule: backup.schedule,
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined, keepLatestCount: backup.keepLatestCount
? Number(backup.keepLatestCount)
: undefined,
}); });
} }
}, [form, form.reset, backup]); }, [form, form.reset, backup]);
@@ -274,10 +276,15 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
<FormItem> <FormItem>
<FormLabel>Keep the latest</FormLabel> <FormLabel>Keep the latest</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} /> <Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud. Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -27,145 +27,149 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props { interface Props {
mariadbId: string; mariadbId: string;
} }
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId }); const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
mariadbId, mariadbId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseName, data?.databaseName,
data?.databaseUser, data?.databaseUser,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid md:grid-cols-2 gap-4 "> <div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4"> <div className="md:col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="3306" placeholder="3306"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */} {/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -17,236 +17,252 @@ import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
mariadbId: string; mariadbId: string;
} }
export const ShowGeneralMariadb = ({ mariadbId }: Props) => { export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
const { data, refetch } = api.mariadb.one.useQuery( const { data, refetch } = api.mariadb.one.useQuery(
{ {
mariadbId, mariadbId,
}, },
{ enabled: !!mariadbId } { enabled: !!mariadbId },
); );
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
api.mariadb.reload.useMutation(); api.mariadb.reload.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isLoading: isStarting } =
api.mariadb.start.useMutation(); api.mariadb.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isLoading: isStopping } =
api.mariadb.stop.useMutation(); api.mariadb.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false); const [isDeploying, setIsDeploying] = useState(false);
api.mariadb.deployWithLogs.useSubscription( api.mariadb.deployWithLogs.useSubscription(
{ {
mariadbId: mariadbId, mariadbId: mariadbId,
}, },
{ {
enabled: isDeploying, enabled: isDeploying,
onData(log) { onData(log) {
if (!isDrawerOpen) { if (!isDrawerOpen) {
setIsDrawerOpen(true); setIsDrawerOpen(true);
} }
if (log === "Deployment completed successfully!") { if (log === "Deployment completed successfully!") {
setIsDeploying(false); setIsDeploying(false);
} }
const parsedLogs = parseLogs(log); const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]); setFilteredLogs((prev) => [...prev, ...parsedLogs]);
}, },
onError(error) { onError(error) {
console.error("Deployment logs error:", error); console.error("Deployment logs error:", error);
setIsDeploying(false); setIsDeploying(false);
}, },
} },
); );
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mariadb" title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?" description="Are you sure you want to deploy this mariadb?"
type="default" type="default"
onClick={async () => { onClick={async () => {
setIsDeploying(true); setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
refetch(); refetch();
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads and sets up the MariaDB database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads and sets up the MariaDB database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</DialogAction> </Tooltip>
<DialogAction </Button>
title="Reload Mariadb" </DialogAction>
description="Are you sure you want to reload this mariadb?" </TooltipProvider>
type="default" <TooltipProvider delayDuration={0}>
onClick={async () => { <DialogAction
await reload({ title="Reload Mariadb"
mariadbId: mariadbId, description="Are you sure you want to reload this mariadb?"
appName: data?.appName || "", type="default"
}) onClick={async () => {
.then(() => { await reload({
toast.success("Mariadb reloaded successfully"); mariadbId: mariadbId,
refetch(); appName: data?.appName || "",
}) })
.catch(() => { .then(() => {
toast.error("Error reloading Mariadb"); toast.success("Mariadb reloaded successfully");
}); refetch();
}} })
> .catch(() => {
<Tooltip> toast.error("Error reloading Mariadb");
<TooltipTrigger asChild> });
<Button }}
variant="secondary" >
isLoading={isReloading} <Button
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" variant="secondary"
> isLoading={isReloading}
<RefreshCcw className="size-4 mr-1" /> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
Reload >
</Button> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <RefreshCcw className="size-4 mr-1" />
<p>Restart the MariaDB service without rebuilding</p> Reload
</TooltipContent> </div>
</TooltipPrimitive.Portal> </TooltipTrigger>
</Tooltip> <TooltipPrimitive.Portal>
</DialogAction> <TooltipContent sideOffset={5} className="z-[60]">
{data?.applicationStatus === "idle" ? ( <p>Restart the MariaDB service without rebuilding</p>
<DialogAction </TooltipContent>
title="Start Mariadb" </TooltipPrimitive.Portal>
description="Are you sure you want to start this mariadb?" </Tooltip>
type="default" </Button>
onClick={async () => { </DialogAction>
await start({ </TooltipProvider>
mariadbId: mariadbId, {data?.applicationStatus === "idle" ? (
}) <TooltipProvider delayDuration={0}>
.then(() => { <DialogAction
toast.success("Mariadb started successfully"); title="Start Mariadb"
refetch(); description="Are you sure you want to start this mariadb?"
}) type="default"
.catch(() => { onClick={async () => {
toast.error("Error starting Mariadb"); await start({
}); mariadbId: mariadbId,
}} })
> .then(() => {
<Tooltip> toast.success("Mariadb started successfully");
<TooltipTrigger asChild> refetch();
<Button })
variant="secondary" .catch(() => {
isLoading={isStarting} toast.error("Error starting Mariadb");
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" });
> }}
<CheckCircle2 className="size-4 mr-1" /> >
Start <Button
</Button> variant="secondary"
</TooltipTrigger> isLoading={isStarting}
<TooltipPrimitive.Portal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
<TooltipContent sideOffset={5} className="z-[60]"> >
<p> <Tooltip>
Start the MariaDB database (requires a previous <TooltipTrigger asChild>
successful setup) <div className="flex items-center">
</p> <CheckCircle2 className="size-4 mr-1" />
</TooltipContent> Start
</TooltipPrimitive.Portal> </div>
</Tooltip> </TooltipTrigger>
</DialogAction> <TooltipPrimitive.Portal>
) : ( <TooltipContent sideOffset={5} className="z-[60]">
<DialogAction <p>
title="Stop Mariadb" Start the MariaDB database (requires a previous
description="Are you sure you want to stop this mariadb?" successful setup)
onClick={async () => { </p>
await stop({ </TooltipContent>
mariadbId: mariadbId, </TooltipPrimitive.Portal>
}) </Tooltip>
.then(() => { </Button>
toast.success("Mariadb stopped successfully"); </DialogAction>
refetch(); </TooltipProvider>
}) ) : (
.catch(() => { <TooltipProvider delayDuration={0}>
toast.error("Error stopping Mariadb"); <DialogAction
}); title="Stop Mariadb"
}} description="Are you sure you want to stop this mariadb?"
> onClick={async () => {
<Tooltip> await stop({
<TooltipTrigger asChild> mariadbId: mariadbId,
<Button })
variant="destructive" .then(() => {
isLoading={isStopping} toast.success("Mariadb stopped successfully");
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" refetch();
> })
<Ban className="size-4 mr-1" /> .catch(() => {
Stop toast.error("Error stopping Mariadb");
</Button> });
</TooltipTrigger> }}
<TooltipPrimitive.Portal> >
<TooltipContent sideOffset={5} className="z-[60]"> <Button
<p>Stop the currently running MariaDB database</p> variant="destructive"
</TooltipContent> isLoading={isStopping}
</TooltipPrimitive.Portal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
</Tooltip> >
</DialogAction> <Tooltip>
)} <TooltipTrigger asChild>
</TooltipProvider> <div className="flex items-center">
<DockerTerminalModal <Ban className="size-4 mr-1" />
appName={data?.appName || ""} Stop
serverId={data?.serverId || ""} </div>
> </TooltipTrigger>
<Tooltip> <TooltipPrimitive.Portal>
<TooltipTrigger asChild> <TooltipContent sideOffset={5} className="z-[60]">
<Button <p>Stop the currently running MariaDB database</p>
variant="outline" </TooltipContent>
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2" </TooltipPrimitive.Portal>
> </Tooltip>
<Terminal className="size-4" /> </Button>
Open Terminal </DialogAction>
</Button> </TooltipProvider>
</TooltipTrigger> )}
<TooltipPrimitive.Portal> <DockerTerminalModal
<TooltipContent sideOffset={5} className="z-[60]"> appName={data?.appName || ""}
<p>Open a terminal to the MariaDB container</p> serverId={data?.serverId || ""}
</TooltipContent> >
</TooltipPrimitive.Portal> <Button
</Tooltip> variant="outline"
</DockerTerminalModal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
</CardContent> >
</Card> <Tooltip>
<DrawerLogs <TooltipTrigger asChild>
isOpen={isDrawerOpen} <div className="flex items-center">
onClose={() => { <Terminal className="size-4 mr-1" />
setIsDrawerOpen(false); Open Terminal
setFilteredLogs([]); </div>
setIsDeploying(false); </TooltipTrigger>
refetch(); <TooltipPrimitive.Portal>
}} <TooltipContent sideOffset={5} className="z-[60]">
filteredLogs={filteredLogs} <p>Open a terminal to the MariaDB container</p>
/> </TooltipContent>
</div> </TooltipPrimitive.Portal>
</> </Tooltip>
); </Button>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
}; };

View File

@@ -27,144 +27,148 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props { interface Props {
mongoId: string; mongoId: string;
} }
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => { export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mongo.one.useQuery({ mongoId }); const { data, refetch } = api.mongo.one.useQuery({ mongoId });
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
mongoId, mongoId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`; return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseUser, data?.databaseUser,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="27017" placeholder="27017"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -3,10 +3,10 @@ import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import * as TooltipPrimitive from "@radix-ui/react-tooltip";
@@ -16,236 +16,246 @@ import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
mongoId: string; mongoId: string;
} }
export const ShowGeneralMongo = ({ mongoId }: Props) => { export const ShowGeneralMongo = ({ mongoId }: Props) => {
const { data, refetch } = api.mongo.one.useQuery( const { data, refetch } = api.mongo.one.useQuery(
{ {
mongoId, mongoId,
}, },
{ enabled: !!mongoId } { enabled: !!mongoId },
); );
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
api.mongo.reload.useMutation(); api.mongo.reload.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isLoading: isStarting } =
api.mongo.start.useMutation(); api.mongo.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isLoading: isStopping } =
api.mongo.stop.useMutation(); api.mongo.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false); const [isDeploying, setIsDeploying] = useState(false);
api.mongo.deployWithLogs.useSubscription( api.mongo.deployWithLogs.useSubscription(
{ {
mongoId: mongoId, mongoId: mongoId,
}, },
{ {
enabled: isDeploying, enabled: isDeploying,
onData(log) { onData(log) {
if (!isDrawerOpen) { if (!isDrawerOpen) {
setIsDrawerOpen(true); setIsDrawerOpen(true);
} }
if (log === "Deployment completed successfully!") { if (log === "Deployment completed successfully!") {
setIsDeploying(false); setIsDeploying(false);
} }
const parsedLogs = parseLogs(log); const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]); setFilteredLogs((prev) => [...prev, ...parsedLogs]);
}, },
onError(error) { onError(error) {
console.error("Deployment logs error:", error); console.error("Deployment logs error:", error);
setIsDeploying(false); setIsDeploying(false);
}, },
} },
); );
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mongo" title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?" description="Are you sure you want to deploy this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
setIsDeploying(true); setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
refetch(); refetch();
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads and sets up the MongoDB database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads and sets up the MongoDB database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</DialogAction> </Tooltip>
<DialogAction </Button>
title="Reload Mongo" </DialogAction>
description="Are you sure you want to reload this mongo?" <DialogAction
type="default" title="Reload Mongo"
onClick={async () => { description="Are you sure you want to reload this mongo?"
await reload({ type="default"
mongoId: mongoId, onClick={async () => {
appName: data?.appName || "", await reload({
}) mongoId: mongoId,
.then(() => { appName: data?.appName || "",
toast.success("Mongo reloaded successfully"); })
refetch(); .then(() => {
}) toast.success("Mongo reloaded successfully");
.catch(() => { refetch();
toast.error("Error reloading Mongo"); })
}); .catch(() => {
}} toast.error("Error reloading Mongo");
> });
<Tooltip> }}
<TooltipTrigger asChild> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<RefreshCcw className="size-4 mr-1" /> <Tooltip>
Reload <TooltipTrigger asChild>
</Button> <div className="flex items-center">
</TooltipTrigger> <RefreshCcw className="size-4 mr-1" />
<TooltipPrimitive.Portal> Reload
<TooltipContent sideOffset={5} className="z-[60]"> </div>
<p>Restart the MongoDB service without rebuilding</p> </TooltipTrigger>
</TooltipContent> <TooltipPrimitive.Portal>
</TooltipPrimitive.Portal> <TooltipContent sideOffset={5} className="z-[60]">
</Tooltip> <p>Restart the MongoDB service without rebuilding</p>
</DialogAction> </TooltipContent>
{data?.applicationStatus === "idle" ? ( </TooltipPrimitive.Portal>
<DialogAction </Tooltip>
title="Start Mongo" </Button>
description="Are you sure you want to start this mongo?" </DialogAction>
type="default" {data?.applicationStatus === "idle" ? (
onClick={async () => { <DialogAction
await start({ title="Start Mongo"
mongoId: mongoId, description="Are you sure you want to start this mongo?"
}) type="default"
.then(() => { onClick={async () => {
toast.success("Mongo started successfully"); await start({
refetch(); mongoId: mongoId,
}) })
.catch(() => { .then(() => {
toast.error("Error starting Mongo"); toast.success("Mongo started successfully");
}); refetch();
}} })
> .catch(() => {
<Tooltip> toast.error("Error starting Mongo");
<TooltipTrigger asChild> });
<Button }}
variant="secondary" >
isLoading={isStarting} <Button
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" variant="secondary"
> isLoading={isStarting}
<CheckCircle2 className="size-4 mr-1" /> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
Start >
</Button> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <CheckCircle2 className="size-4 mr-1" />
<p> Start
Start the MongoDB database (requires a previous </div>
successful setup) </TooltipTrigger>
</p> <TooltipPrimitive.Portal>
</TooltipContent> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipPrimitive.Portal> <p>
</Tooltip> Start the MongoDB database (requires a previous
</DialogAction> successful setup)
) : ( </p>
<DialogAction </TooltipContent>
title="Stop Mongo" </TooltipPrimitive.Portal>
description="Are you sure you want to stop this mongo?" </Tooltip>
onClick={async () => { </Button>
await stop({ </DialogAction>
mongoId: mongoId, ) : (
}) <DialogAction
.then(() => { title="Stop Mongo"
toast.success("Mongo stopped successfully"); description="Are you sure you want to stop this mongo?"
refetch(); onClick={async () => {
}) await stop({
.catch(() => { mongoId: mongoId,
toast.error("Error stopping Mongo"); })
}); .then(() => {
}} toast.success("Mongo stopped successfully");
> refetch();
<Tooltip> })
<TooltipTrigger asChild> .catch(() => {
<Button toast.error("Error stopping Mongo");
variant="destructive" });
isLoading={isStopping} }}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" >
> <Button
<Ban className="size-4 mr-1" /> variant="destructive"
Stop isLoading={isStopping}
</Button> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
</TooltipTrigger> >
<TooltipPrimitive.Portal> <Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipTrigger asChild>
<p>Stop the currently running MongoDB database</p> <div className="flex items-center">
</TooltipContent> <Ban className="size-4 mr-1" />
</TooltipPrimitive.Portal> Stop
</Tooltip> </div>
</DialogAction> </TooltipTrigger>
)} <TooltipPrimitive.Portal>
</TooltipProvider> <TooltipContent sideOffset={5} className="z-[60]">
<DockerTerminalModal <p>Stop the currently running MongoDB database</p>
appName={data?.appName || ""} </TooltipContent>
serverId={data?.serverId || ""} </TooltipPrimitive.Portal>
> </Tooltip>
<Tooltip> </Button>
<TooltipTrigger asChild> </DialogAction>
<Button )}
variant="outline" </TooltipProvider>
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2" <DockerTerminalModal
> appName={data?.appName || ""}
<Terminal className="size-4" /> serverId={data?.serverId || ""}
Open Terminal >
</Button> <Button
</TooltipTrigger> variant="outline"
<TooltipPrimitive.Portal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
<TooltipContent sideOffset={5} className="z-[60]"> >
<p>Open a terminal to the MongoDB container</p> <Tooltip>
</TooltipContent> <TooltipTrigger asChild>
</TooltipPrimitive.Portal> <div className="flex items-center">
</Tooltip> <Terminal className="size-4 mr-1" />
</DockerTerminalModal> Open Terminal
</CardContent> </div>
</Card> </TooltipTrigger>
<DrawerLogs <TooltipPrimitive.Portal>
isOpen={isDrawerOpen} <TooltipContent sideOffset={5} className="z-[60]">
onClose={() => { <p>Open a terminal to the MongoDB container</p>
setIsDrawerOpen(false); </TooltipContent>
setFilteredLogs([]); </TooltipPrimitive.Portal>
setIsDeploying(false); </Tooltip>
refetch(); </Button>
}} </DockerTerminalModal>
filteredLogs={filteredLogs} </CardContent>
/> </Card>
</div> <DrawerLogs
</> isOpen={isDrawerOpen}
); onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
}; };

View File

@@ -27,144 +27,148 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props { interface Props {
mysqlId: string; mysqlId: string;
} }
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => { export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mysql.one.useQuery({ mysqlId }); const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
mysqlId, mysqlId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
data?.databaseName, data?.databaseName,
data?.databaseUser, data?.databaseUser,
form, form,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="3306" placeholder="3306"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput disabled value={connectionUrl} /> <ToggleVisibilityInput disabled value={connectionUrl} />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -16,234 +16,244 @@ import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
mysqlId: string; mysqlId: string;
} }
export const ShowGeneralMysql = ({ mysqlId }: Props) => { export const ShowGeneralMysql = ({ mysqlId }: Props) => {
const { data, refetch } = api.mysql.one.useQuery( const { data, refetch } = api.mysql.one.useQuery(
{ {
mysqlId, mysqlId,
}, },
{ enabled: !!mysqlId } { enabled: !!mysqlId },
); );
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
api.mysql.reload.useMutation(); api.mysql.reload.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isLoading: isStarting } =
api.mysql.start.useMutation(); api.mysql.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isLoading: isStopping } =
api.mysql.stop.useMutation(); api.mysql.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false); const [isDeploying, setIsDeploying] = useState(false);
api.mysql.deployWithLogs.useSubscription( api.mysql.deployWithLogs.useSubscription(
{ {
mysqlId: mysqlId, mysqlId: mysqlId,
}, },
{ {
enabled: isDeploying, enabled: isDeploying,
onData(log) { onData(log) {
if (!isDrawerOpen) { if (!isDrawerOpen) {
setIsDrawerOpen(true); setIsDrawerOpen(true);
} }
if (log === "Deployment completed successfully!") { if (log === "Deployment completed successfully!") {
setIsDeploying(false); setIsDeploying(false);
} }
const parsedLogs = parseLogs(log); const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]); setFilteredLogs((prev) => [...prev, ...parsedLogs]);
}, },
onError(error) { onError(error) {
console.error("Deployment logs error:", error); console.error("Deployment logs error:", error);
setIsDeploying(false); setIsDeploying(false);
}, },
} },
); );
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mysql" title="Deploy MySQL"
description="Are you sure you want to deploy this mysql?" description="Are you sure you want to deploy this mysql?"
type="default" type="default"
onClick={async () => { onClick={async () => {
setIsDeploying(true); setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
refetch(); refetch();
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads and sets up the MySQL database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads and sets up the MySQL database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</DialogAction> </Tooltip>
<DialogAction </Button>
title="Reload Mysql" </DialogAction>
description="Are you sure you want to reload this mysql?" <DialogAction
type="default" title="Reload MySQL"
onClick={async () => { description="Are you sure you want to reload this mysql?"
await reload({ type="default"
mysqlId: mysqlId, onClick={async () => {
appName: data?.appName || "", await reload({
}) mysqlId: mysqlId,
.then(() => { appName: data?.appName || "",
toast.success("Mysql reloaded successfully"); })
refetch(); .then(() => {
}) toast.success("MySQL reloaded successfully");
.catch(() => { refetch();
toast.error("Error reloading Mysql"); })
}); .catch(() => {
}} toast.error("Error reloading MySQL");
> });
<Tooltip> }}
<TooltipTrigger asChild> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<RefreshCcw className="size-4 mr-1" /> <Tooltip>
Reload <TooltipTrigger asChild>
</Button> <div className="flex items-center">
</TooltipTrigger> <RefreshCcw className="size-4 mr-1" />
<TooltipPrimitive.Portal> Reload
<TooltipContent sideOffset={5} className="z-[60]"> </div>
<p>Restart the MySQL service without rebuilding</p> </TooltipTrigger>
</TooltipContent> <TooltipPrimitive.Portal>
</TooltipPrimitive.Portal> <TooltipContent sideOffset={5} className="z-[60]">
</Tooltip> <p>Restart the MySQL service without rebuilding</p>
</DialogAction> </TooltipContent>
{data?.applicationStatus === "idle" ? ( </TooltipPrimitive.Portal>
<DialogAction </Tooltip>
title="Start Mysql" </Button>
description="Are you sure you want to start this mysql?" </DialogAction>
type="default" {data?.applicationStatus === "idle" ? (
onClick={async () => { <DialogAction
await start({ title="Start MySQL"
mysqlId: mysqlId, description="Are you sure you want to start this mysql?"
}) type="default"
.then(() => { onClick={async () => {
toast.success("Mysql started successfully"); await start({
refetch(); mysqlId: mysqlId,
}) })
.catch(() => { .then(() => {
toast.error("Error starting Mysql"); toast.success("MySQL started successfully");
}); refetch();
}} })
> .catch(() => {
<Tooltip> toast.error("Error starting MySQL");
<TooltipTrigger asChild> });
<Button }}
variant="secondary" >
isLoading={isStarting} <Button
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" variant="secondary"
> isLoading={isStarting}
<CheckCircle2 className="size-4 mr-1" /> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
Start >
</Button> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <CheckCircle2 className="size-4 mr-1" />
<p> Start
Start the MySQL database (requires a previous </div>
successful setup) </TooltipTrigger>
</p> <TooltipPrimitive.Portal>
</TooltipContent> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipPrimitive.Portal> <p>
</Tooltip> Start the MySQL database (requires a previous
</DialogAction> successful setup)
) : ( </p>
<DialogAction </TooltipContent>
title="Stop Mysql" </TooltipPrimitive.Portal>
description="Are you sure you want to stop this mysql?" </Tooltip>
onClick={async () => { </Button>
await stop({ </DialogAction>
mysqlId: mysqlId, ) : (
}) <DialogAction
.then(() => { title="Stop MySQL"
toast.success("Mysql stopped successfully"); description="Are you sure you want to stop this mysql?"
refetch(); onClick={async () => {
}) await stop({
.catch(() => { mysqlId: mysqlId,
toast.error("Error stopping Mysql"); })
}); .then(() => {
}} toast.success("MySQL stopped successfully");
> refetch();
<Tooltip> })
<TooltipTrigger asChild> .catch(() => {
<Button toast.error("Error stopping MySQL");
variant="destructive" });
isLoading={isStopping} }}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" >
> <Button
<Ban className="size-4 mr-1" /> variant="destructive"
Stop isLoading={isStopping}
</Button> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
</TooltipTrigger> >
<TooltipPrimitive.Portal> <Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipTrigger asChild>
<p>Stop the currently running MySQL database</p> <div className="flex items-center">
</TooltipContent> <Ban className="size-4 mr-1" />
</TooltipPrimitive.Portal> Stop
</Tooltip> </div>
</DialogAction> </TooltipTrigger>
)} <TooltipPrimitive.Portal>
</TooltipProvider> <TooltipContent sideOffset={5} className="z-[60]">
<DockerTerminalModal <p>Stop the currently running MySQL database</p>
appName={data?.appName || ""} </TooltipContent>
serverId={data?.serverId || ""} </TooltipPrimitive.Portal>
> </Tooltip>
<Tooltip> </Button>
<TooltipTrigger asChild> </DialogAction>
<Button )}
variant="outline" </TooltipProvider>
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2" <DockerTerminalModal
> appName={data?.appName || ""}
<Terminal className="size-4" /> serverId={data?.serverId || ""}
Open Terminal >
</Button> <Button
</TooltipTrigger> variant="outline"
<TooltipPrimitive.Portal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
<TooltipContent sideOffset={5} className="z-[60]"> >
<p>Open a terminal to the MySQL container</p> <Tooltip>
</TooltipContent> <TooltipTrigger asChild>
</TooltipPrimitive.Portal> <div className="flex items-center">
</Tooltip> <Terminal className="size-4 mr-1" />
</DockerTerminalModal> Open Terminal
</CardContent> </div>
</Card> </TooltipTrigger>
<DrawerLogs <TooltipPrimitive.Portal>
isOpen={isDrawerOpen} <TooltipContent sideOffset={5} className="z-[60]">
onClose={() => { <p>Open a terminal to the MySQL container</p>
setIsDrawerOpen(false); </TooltipContent>
setFilteredLogs([]); </TooltipPrimitive.Portal>
setIsDeploying(false); </Tooltip>
refetch(); </Button>
}} </DockerTerminalModal>
filteredLogs={filteredLogs} </CardContent>
/> </Card>
</div> <DrawerLogs
</> isOpen={isDrawerOpen}
); onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
}; };

View File

@@ -27,146 +27,150 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.postgres.one.useQuery({ postgresId }); const { data, refetch } = api.postgres.one.useQuery({ postgresId });
const { mutateAsync, isLoading } = const { mutateAsync, isLoading } =
api.postgres.saveExternalPort.useMutation(); api.postgres.saveExternalPort.useMutation();
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
postgresId, postgresId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseName, data?.databaseName,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="5432" placeholder="5432"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -74,7 +74,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider disableHoverableContent={false}> <TooltipProvider disableHoverableContent={false}>
<DialogAction <DialogAction
title="Deploy Postgres" title="Deploy PostgreSQL"
description="Are you sure you want to deploy this postgres?" description="Are you sure you want to deploy this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
@@ -83,26 +83,28 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
refetch(); refetch();
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads and sets up the PostgreSQL database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads and sets up the PostgreSQL database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
title="Reload Postgres" title="Reload PostgreSQL"
description="Are you sure you want to reload this postgres?" description="Are you sure you want to reload this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
@@ -111,35 +113,37 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
appName: data?.appName || "", appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Postgres reloaded successfully"); toast.success("PostgreSQL reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading Postgres"); toast.error("Error reloading PostgreSQL");
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={isReloading}
variant="secondary" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isReloading} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<RefreshCcw className="size-4 mr-1" /> <div className="flex items-center">
Reload <RefreshCcw className="size-4 mr-1" />
</Button> Reload
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Reload the PostgreSQL without rebuilding it</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Restart the PostgreSQL service without rebuilding</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Postgres" title="Start PostgreSQL"
description="Are you sure you want to start this postgres?" description="Are you sure you want to start this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
@@ -147,69 +151,73 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
postgresId: postgresId, postgresId: postgresId,
}) })
.then(() => { .then(() => {
toast.success("Postgres started successfully"); toast.success("PostgreSQL started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error starting Postgres"); toast.error("Error starting PostgreSQL");
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<Button isLoading={isStarting}
variant="secondary" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isStarting} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<CheckCircle2 className="size-4 mr-1" /> <div className="flex items-center">
Start <CheckCircle2 className="size-4 mr-1" />
</Button> Start
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p> <TooltipContent sideOffset={5} className="z-[60]">
Start the PostgreSQL database (requires a previous <p>
successful setup) Start the PostgreSQL database (requires a previous
</p> successful setup)
</TooltipContent> </p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
) : ( ) : (
<DialogAction <DialogAction
title="Stop Postgres" title="Stop PostgreSQL"
description="Are you sure you want to stop this postgres?" description="Are you sure you want to stop this postgres?"
onClick={async () => { onClick={async () => {
await stop({ await stop({
postgresId: postgresId, postgresId: postgresId,
}) })
.then(() => { .then(() => {
toast.success("Postgres stopped successfully"); toast.success("PostgreSQL stopped successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping Postgres"); toast.error("Error stopping PostgreSQL");
}); });
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="destructive"
<Button isLoading={isStopping}
variant="destructive" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={isStopping} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Ban className="size-4 mr-1" /> <div className="flex items-center">
Stop <Ban className="size-4 mr-1" />
</Button> Stop
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Stop the currently running PostgreSQL database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Stop the currently running PostgreSQL database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -221,8 +229,19 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
variant="outline" variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Terminal className="size-4 mr-1" /> <Tooltip>
Open Terminal <TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the PostgreSQL container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DockerTerminalModal> </DockerTerminalModal>
</CardContent> </CardContent>

View File

@@ -5,58 +5,58 @@ import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => { export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
const { data } = api.postgres.one.useQuery({ postgresId }); const { data } = api.postgres.one.useQuery({ postgresId });
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Internal Credentials</CardTitle> <CardTitle className="text-xl">Internal Credentials</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-row gap-4"> <CardContent className="flex w-full flex-row gap-4">
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Database Name</Label> <Label>Database Name</Label>
<Input disabled value={data?.databaseName} /> <Input disabled value={data?.databaseName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
value={data?.databasePassword} value={data?.databasePassword}
disabled disabled
/> />
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Port (Container)</Label> <Label>Internal Port (Container)</Label>
<Input disabled value="5432" /> <Input disabled value="5432" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Connection URL </Label> <Label>Internal Connection URL </Label>
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`} value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
/> />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };
// ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w // ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w

View File

@@ -28,139 +28,139 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const updatePostgresSchema = z.object({ const updatePostgresSchema = z.object({
name: z.string().min(1, { name: z.string().min(1, {
message: "Name is required", message: "Name is required",
}), }),
description: z.string().optional(), description: z.string().optional(),
}); });
type UpdatePostgres = z.infer<typeof updatePostgresSchema>; type UpdatePostgres = z.infer<typeof updatePostgresSchema>;
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const UpdatePostgres = ({ postgresId }: Props) => { export const UpdatePostgres = ({ postgresId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } = const { mutateAsync, error, isError, isLoading } =
api.postgres.update.useMutation(); api.postgres.update.useMutation();
const { data } = api.postgres.one.useQuery( const { data } = api.postgres.one.useQuery(
{ {
postgresId, postgresId,
}, },
{ {
enabled: !!postgresId, enabled: !!postgresId,
} },
); );
const form = useForm<UpdatePostgres>({ const form = useForm<UpdatePostgres>({
defaultValues: { defaultValues: {
description: data?.description ?? "", description: data?.description ?? "",
name: data?.name ?? "", name: data?.name ?? "",
}, },
resolver: zodResolver(updatePostgresSchema), resolver: zodResolver(updatePostgresSchema),
}); });
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
description: data.description ?? "", description: data.description ?? "",
name: data.name, name: data.name,
}); });
} }
}, [data, form, form.reset]); }, [data, form, form.reset]);
const onSubmit = async (formData: UpdatePostgres) => { const onSubmit = async (formData: UpdatePostgres) => {
await mutateAsync({ await mutateAsync({
name: formData.name, name: formData.name,
postgresId: postgresId, postgresId: postgresId,
description: formData.description || "", description: formData.description || "",
}) })
.then(() => { .then(() => {
toast.success("Postgres updated successfully"); toast.success("Postgres updated successfully");
utils.postgres.one.invalidate({ utils.postgres.one.invalidate({
postgresId: postgresId, postgresId: postgresId,
}); });
setIsOpen(false); setIsOpen(false);
}) })
.catch(() => { .catch(() => {
toast.error("Error updating Postgres"); toast.error("Error updating Postgres");
}) })
.finally(() => {}); .finally(() => {});
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2" className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" /> <PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Modify Postgres</DialogTitle> <DialogTitle>Modify Postgres</DialogTitle>
<DialogDescription>Update the Postgres data</DialogDescription> <DialogDescription>Update the Postgres data</DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid items-center gap-4"> <div className="grid items-center gap-4">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-postgres" id="hook-form-update-postgres"
className="grid w-full gap-4 " className="grid w-full gap-4 "
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Vandelay Industries" {...field} /> <Input placeholder="Vandelay Industries" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Description about your project..." placeholder="Description about your project..."
className="resize-none" className="resize-none"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isLoading}
form="hook-form-update-postgres" form="hook-form-update-postgres"
type="submit" type="submit"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
Update Update
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View File

@@ -67,7 +67,7 @@ import {
SearchIcon, SearchIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url"; const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";

View File

@@ -13,7 +13,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useState } from "react";
const examples = [ const examples = [
"Make a personal blog", "Make a personal blog",
@@ -23,7 +22,7 @@ const examples = [
"Sendgrid service opensource analogue", "Sendgrid service opensource analogue",
]; ];
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => { export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
// Get servers from the API // Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();

View File

@@ -0,0 +1,172 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
export type Services = {
appName: string;
serverId?: string | null;
name: string;
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
};
interface DuplicateProjectProps {
projectId: string;
services: Services[];
selectedServiceIds: string[];
}
export const DuplicateProject = ({
projectId,
services,
selectedServiceIds,
}: DuplicateProjectProps) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const utils = api.useUtils();
const router = useRouter();
const selectedServices = services.filter((service) =>
selectedServiceIds.includes(service.id),
);
const { mutateAsync: duplicateProject, isLoading } =
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
toast.success("Project duplicated successfully");
setOpen(false);
router.push(`/dashboard/project/${newProject.projectId}`);
},
onError: (error) => {
toast.error(error.message);
},
});
const handleDuplicate = async () => {
if (!name) {
toast.error("Project name is required");
return;
}
await duplicateProject({
sourceProjectId: projectId,
name,
description,
includeServices: true,
selectedServices: selectedServices.map((service) => ({
id: service.id,
type: service.type,
})),
});
};
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
// Reset form when closing
setName("");
setDescription("");
}
}}
>
<DialogTrigger asChild>
<Button variant="ghost" className="w-full justify-start">
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Project</DialogTitle>
<DialogDescription>
Create a new project with the selected services
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
{selectedServices.map((service) => (
<div key={service.id} className="flex items-center space-x-2">
<span className="text-sm">
{service.name} ({service.type})
</span>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button onClick={handleDuplicate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Duplicating...
</>
) : (
"Duplicate"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -186,7 +186,9 @@ export const ShowProjects = () => {
target="_blank" target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`} href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
> >
<span className="truncate">{domain.host}</span> <span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -222,7 +224,9 @@ export const ShowProjects = () => {
target="_blank" target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`} href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
> >
<span className="truncate">{domain.host}</span> <span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -27,138 +27,142 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props { interface Props {
redisId: string; redisId: string;
} }
export const ShowExternalRedisCredentials = ({ redisId }: Props) => { export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.redis.one.useQuery({ redisId }); const { data, refetch } = api.redis.one.useQuery({ redisId });
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
redisId, redisId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const _hostname = window.location.hostname; const _hostname = window.location.hostname;
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `redis://default:${data?.databasePassword}@${getIp}:${port}`; return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]); }, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="6379" placeholder="6379"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -17,235 +17,245 @@ import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
redisId: string; redisId: string;
} }
export const ShowGeneralRedis = ({ redisId }: Props) => { export const ShowGeneralRedis = ({ redisId }: Props) => {
const { data, refetch } = api.redis.one.useQuery( const { data, refetch } = api.redis.one.useQuery(
{ {
redisId, redisId,
}, },
{ enabled: !!redisId } { enabled: !!redisId },
); );
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
api.redis.reload.useMutation(); api.redis.reload.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isLoading: isStarting } =
api.redis.start.useMutation(); api.redis.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isLoading: isStopping } =
api.redis.stop.useMutation(); api.redis.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false); const [isDeploying, setIsDeploying] = useState(false);
api.redis.deployWithLogs.useSubscription( api.redis.deployWithLogs.useSubscription(
{ {
redisId: redisId, redisId: redisId,
}, },
{ {
enabled: isDeploying, enabled: isDeploying,
onData(log) { onData(log) {
if (!isDrawerOpen) { if (!isDrawerOpen) {
setIsDrawerOpen(true); setIsDrawerOpen(true);
} }
if (log === "Deployment completed successfully!") { if (log === "Deployment completed successfully!") {
setIsDeploying(false); setIsDeploying(false);
} }
const parsedLogs = parseLogs(log); const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]); setFilteredLogs((prev) => [...prev, ...parsedLogs]);
}, },
onError(error) { onError(error) {
console.error("Deployment logs error:", error); console.error("Deployment logs error:", error);
setIsDeploying(false); setIsDeploying(false);
}, },
} },
); );
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Redis" title="Deploy Redis"
description="Are you sure you want to deploy this redis?" description="Are you sure you want to deploy this redis?"
type="default" type="default"
onClick={async () => { onClick={async () => {
setIsDeploying(true); setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
refetch(); refetch();
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads and sets up the Redis database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads and sets up the Redis database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</DialogAction> </Tooltip>
<DialogAction </Button>
title="Reload Redis" </DialogAction>
description="Are you sure you want to reload this redis?" <DialogAction
type="default" title="Reload Redis"
onClick={async () => { description="Are you sure you want to reload this redis?"
await reload({ type="default"
redisId: redisId, onClick={async () => {
appName: data?.appName || "", await reload({
}) redisId: redisId,
.then(() => { appName: data?.appName || "",
toast.success("Redis reloaded successfully"); })
refetch(); .then(() => {
}) toast.success("Redis reloaded successfully");
.catch(() => { refetch();
toast.error("Error reloading Redis"); })
}); .catch(() => {
}} toast.error("Error reloading Redis");
> });
<Tooltip> }}
<TooltipTrigger asChild> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<RefreshCcw className="size-4 mr-1" /> <Tooltip>
Reload <TooltipTrigger asChild>
</Button> <div className="flex items-center">
</TooltipTrigger> <RefreshCcw className="size-4 mr-1" />
<TooltipPrimitive.Portal> Reload
<TooltipContent sideOffset={5} className="z-[60]"> </div>
<p>Restart the Redis service without rebuilding</p> </TooltipTrigger>
</TooltipContent> <TooltipPrimitive.Portal>
</TooltipPrimitive.Portal> <TooltipContent sideOffset={5} className="z-[60]">
</Tooltip> <p>Restart the Redis service without rebuilding</p>
</DialogAction> </TooltipContent>
{data?.applicationStatus === "idle" ? ( </TooltipPrimitive.Portal>
<DialogAction </Tooltip>
title="Start Redis" </Button>
description="Are you sure you want to start this redis?" </DialogAction>
type="default" {data?.applicationStatus === "idle" ? (
onClick={async () => { <DialogAction
await start({ title="Start Redis"
redisId: redisId, description="Are you sure you want to start this redis?"
}) type="default"
.then(() => { onClick={async () => {
toast.success("Redis started successfully"); await start({
refetch(); redisId: redisId,
}) })
.catch(() => { .then(() => {
toast.error("Error starting Redis"); toast.success("Redis started successfully");
}); refetch();
}} })
> .catch(() => {
<Tooltip> toast.error("Error starting Redis");
<TooltipTrigger asChild> });
<Button }}
variant="secondary" >
isLoading={isStarting} <Button
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" variant="secondary"
> isLoading={isStarting}
<CheckCircle2 className="size-4 mr-1" /> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
Start >
</Button> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <CheckCircle2 className="size-4 mr-1" />
<p> Start
Start the Redis database (requires a previous </div>
successful setup) </TooltipTrigger>
</p> <TooltipPrimitive.Portal>
</TooltipContent> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipPrimitive.Portal> <p>
</Tooltip> Start the Redis database (requires a previous
</DialogAction> successful setup)
) : ( </p>
<DialogAction </TooltipContent>
title="Stop Redis" </TooltipPrimitive.Portal>
description="Are you sure you want to stop this redis?" </Tooltip>
onClick={async () => { </Button>
await stop({ </DialogAction>
redisId: redisId, ) : (
}) <DialogAction
.then(() => { title="Stop Redis"
toast.success("Redis stopped successfully"); description="Are you sure you want to stop this redis?"
refetch(); onClick={async () => {
}) await stop({
.catch(() => { redisId: redisId,
toast.error("Error stopping Redis"); })
}); .then(() => {
}} toast.success("Redis stopped successfully");
> refetch();
<Tooltip> })
<TooltipTrigger asChild> .catch(() => {
<Button toast.error("Error stopping Redis");
variant="destructive" });
isLoading={isStopping} }}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" >
> <Button
<Ban className="size-4 mr-1" /> variant="destructive"
Stop isLoading={isStopping}
</Button> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
</TooltipTrigger> >
<TooltipPrimitive.Portal> <Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipTrigger asChild>
<p>Stop the currently running Redis database</p> <div className="flex items-center">
</TooltipContent> <Ban className="size-4 mr-1" />
</TooltipPrimitive.Portal> Stop
</Tooltip> </div>
</DialogAction> </TooltipTrigger>
)} <TooltipPrimitive.Portal>
</TooltipProvider> <TooltipContent sideOffset={5} className="z-[60]">
<DockerTerminalModal <p>Stop the currently running Redis database</p>
appName={data?.appName || ""} </TooltipContent>
serverId={data?.serverId || ""} </TooltipPrimitive.Portal>
> </Tooltip>
<Tooltip> </Button>
<TooltipTrigger asChild> </DialogAction>
<Button )}
variant="outline" </TooltipProvider>
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2" <DockerTerminalModal
> appName={data?.appName || ""}
<Terminal className="size-4" /> serverId={data?.serverId || ""}
Open Terminal >
</Button> <Button
</TooltipTrigger> variant="outline"
<TooltipPrimitive.Portal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
<TooltipContent sideOffset={5} className="z-[60]"> >
<p>Open a terminal to the Redis container</p> <Tooltip>
</TooltipContent> <TooltipTrigger asChild>
</TooltipPrimitive.Portal> <div className="flex items-center">
</Tooltip> <Terminal className="size-4 mr-1" />
</DockerTerminalModal> Open Terminal
</CardContent> </div>
</Card> </TooltipTrigger>
<DrawerLogs <TooltipPrimitive.Portal>
isOpen={isDrawerOpen} <TooltipContent sideOffset={5} className="z-[60]">
onClose={() => { <p>Open a terminal to the Redis container</p>
setIsDrawerOpen(false); </TooltipContent>
setFilteredLogs([]); </TooltipPrimitive.Portal>
setIsDeploying(false); </Tooltip>
refetch(); </Button>
}} </DockerTerminalModal>
filteredLogs={filteredLogs} </CardContent>
/> </Card>
</div> <DrawerLogs
</> isOpen={isDrawerOpen}
); onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
}; };

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api";
import { import {
type ChartConfig, type ChartConfig,
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import { api } from "@/utils/api";
import { import {
Area, Area,
AreaChart, AreaChart,

View File

@@ -25,13 +25,13 @@ import {
import { type RouterOutputs, api } from "@/utils/api"; import { type RouterOutputs, api } from "@/utils/api";
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
ArrowDownUp,
AlertCircle, AlertCircle,
InfoIcon, ArrowDownUp,
Calendar as CalendarIcon, Calendar as CalendarIcon,
InfoIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart"; import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table"; import { RequestsTable } from "./requests-table";

View File

@@ -1,14 +1,22 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@@ -17,22 +25,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import copy from "copy-to-clipboard";
import { CodeEditor } from "@/components/shared/code-editor";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),

View File

@@ -1,3 +1,5 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -7,13 +9,11 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react"; import { formatDistanceToNow } from "date-fns";
import { Clock, ExternalLinkIcon, KeyIcon, Tag, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddApiKey } from "./add-api-key"; import { AddApiKey } from "./add-api-key";
import { Badge } from "@/components/ui/badge";
export const ShowApiKeys = () => { export const ShowApiKeys = () => {
const { data, refetch } = api.user.get.useQuery(); const { data, refetch } = api.user.get.useQuery();

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -48,6 +49,10 @@ export const AddNode = ({ serverId }: Props) => {
Architecture Architecture
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Link> </Link>
<AlertBlock type="warning">
Make sure you use the same architecture as the node you are
adding.
</AlertBlock>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -56,10 +61,10 @@ export const AddNode = ({ serverId }: Props) => {
<TabsTrigger value="worker">Worker</TabsTrigger> <TabsTrigger value="worker">Worker</TabsTrigger>
<TabsTrigger value="manager">Manager</TabsTrigger> <TabsTrigger value="manager">Manager</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="worker" className="pt-4"> <TabsContent value="worker" className="pt-4 overflow-hidden">
<AddWorker serverId={serverId} /> <AddWorker serverId={serverId} />
</TabsContent> </TabsContent>
<TabsContent value="manager" className="pt-4"> <TabsContent value="manager" className="pt-4 overflow-hidden">
<AddManager serverId={serverId} /> <AddManager serverId={serverId} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { import {
DialogDescription, DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react"; import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
@@ -14,56 +15,66 @@ interface Props {
} }
export const AddManager = ({ serverId }: Props) => { export const AddManager = ({ serverId }: Props) => {
const { data } = api.cluster.addManager.useQuery({ serverId }); const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
serverId,
});
return ( return (
<> <>
<div> <CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0"> <DialogHeader>
<DialogHeader> <DialogTitle>Add a new manager</DialogTitle>
<DialogTitle>Add a new manager</DialogTitle> <DialogDescription>Add a new manager</DialogDescription>
<DialogDescription>Add a new manager</DialogDescription> </DialogHeader>
</DialogHeader> {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col gap-2.5 text-sm"> {isLoading ? (
<span>1. Go to your new server and run the following command</span> <Loader2 className="w-full animate-spin text-muted-foreground" />
<span className="bg-muted rounded-lg p-2 flex justify-between"> ) : (
curl https://get.docker.com | sh -s -- --version {data?.version} <>
<button <div className="flex flex-col gap-2.5 text-sm">
type="button" <span>
className="self-center" 1. Go to your new server and run the following command
onClick={() => { </span>
copy( <span className="bg-muted rounded-lg p-2 flex justify-between">
`curl https://get.docker.com | sh -s -- --version ${data?.version}`, curl https://get.docker.com | sh -s -- --version {data?.version}
); <button
toast.success("Copied to clipboard"); type="button"
}} className="self-center"
> onClick={() => {
<CopyIcon className="h-4 w-4 cursor-pointer" /> copy(
</button> `curl https://get.docker.com | sh -s -- --version ${data?.version}`,
</span> );
</div> toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm"> <div className="flex flex-col gap-2.5 text-sm">
<span> <span>
2. Run the following command to add the node(manager) to your 2. Run the following command to add the node(manager) to your
cluster cluster
</span> </span>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command} <span className="bg-muted rounded-lg p-2 flex">
<button {data?.command}
type="button" <button
className="self-start" type="button"
onClick={() => { className="self-start"
copy(data?.command || ""); onClick={() => {
toast.success("Copied to clipboard"); copy(data?.command || "");
}} toast.success("Copied to clipboard");
> }}
<CopyIcon className="h-4 w-4 cursor-pointer" /> >
</button> <CopyIcon className="h-4 w-4 cursor-pointer" />
</span> </button>
</div> </span>
</CardContent> </div>
</div> </>
)}
</CardContent>
</> </>
); );
}; };

View File

@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
Show Nodes Show Swarm Nodes
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen "> <DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">

View File

@@ -35,9 +35,9 @@ import { api } from "@/utils/api";
import { import {
Boxes, Boxes,
HelpCircle, HelpCircle,
Loader2,
LockIcon, LockIcon,
MoreHorizontal, MoreHorizontal,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddNode } from "./add-node"; import { AddNode } from "./add-node";
@@ -144,7 +144,7 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} /> <ShowNodeData data={node} />
{node?.ManagerStatus?.Leader && ( {!node?.ManagerStatus?.Leader && (
<DialogAction <DialogAction
title="Delete Node" title="Delete Node"
description="Are you sure you want to delete this node from the cluster?" description="Are you sure you want to delete this node from the cluster?"

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { import {
DialogDescription, DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react"; import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
@@ -14,54 +15,62 @@ interface Props {
} }
export const AddWorker = ({ serverId }: Props) => { export const AddWorker = ({ serverId }: Props) => {
const { data } = api.cluster.addWorker.useQuery({ serverId }); const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
serverId,
});
return ( return (
<div> <CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0"> <DialogHeader>
<DialogHeader> <DialogTitle>Add a new worker</DialogTitle>
<DialogTitle>Add a new worker</DialogTitle> <DialogDescription>Add a new worker</DialogDescription>
<DialogDescription>Add a new worker</DialogDescription> </DialogHeader>
</DialogHeader> {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col gap-2.5 text-sm"> {isLoading ? (
<span>1. Go to your new server and run the following command</span> <Loader2 className="w-full animate-spin text-muted-foreground" />
<span className="bg-muted rounded-lg p-2 flex justify-between"> ) : (
curl https://get.docker.com | sh -s -- --version {data?.version} <>
<button <div className="flex flex-col gap-2.5 text-sm">
type="button" <span>1. Go to your new server and run the following command</span>
className="self-center" <span className="bg-muted rounded-lg p-2 flex justify-between">
onClick={() => { curl https://get.docker.com | sh -s -- --version {data?.version}
copy( <button
`curl https://get.docker.com | sh -s -- --version ${data?.version}`, type="button"
); className="self-center"
toast.success("Copied to clipboard"); onClick={() => {
}} copy(
> `curl https://get.docker.com | sh -s -- --version ${data?.version}`,
<CopyIcon className="h-4 w-4 cursor-pointer" /> );
</button> toast.success("Copied to clipboard");
</span> }}
</div> >
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm"> <div className="flex flex-col gap-2.5 text-sm">
<span> <span>
2. Run the following command to add the node(worker) to your cluster 2. Run the following command to add the node(worker) to your
</span> cluster
</span>
<span className="bg-muted rounded-lg p-2 flex"> <span className="bg-muted rounded-lg p-2 flex">
{data?.command} {data?.command}
<button <button
type="button" type="button"
className="self-start" className="self-start"
onClick={() => { onClick={() => {
copy(data?.command || ""); copy(data?.command || "");
toast.success("Copied to clipboard"); toast.success("Copied to clipboard");
}} }}
> >
<CopyIcon className="h-4 w-4 cursor-pointer" /> <CopyIcon className="h-4 w-4 cursor-pointer" />
</button> </button>
</span> </span>
</div> </div>
</CardContent> </>
</div> )}
</CardContent>
); );
}; };

View File

@@ -0,0 +1,286 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type GiteaProviderResponse,
getGiteaOAuthUrl,
} from "@/utils/gitea-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { zodResolver } from "@hookform/resolvers/zod";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
giteaUrl: z.string().min(1, {
message: "Gitea URL is required",
}),
clientId: z.string().min(1, {
message: "Client ID is required",
}),
clientSecret: z.string().min(1, {
message: "Client Secret is required",
}),
redirectUri: z.string().min(1, {
message: "Redirect URI is required",
}),
organizationName: z.string().optional(),
});
type Schema = z.infer<typeof Schema>;
export const AddGiteaProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const urlObj = useUrl();
const baseUrl =
typeof urlObj === "string" ? urlObj : (urlObj as any)?.url || "";
const { mutateAsync, error, isError } = api.gitea.create.useMutation();
const webhookUrl = `${baseUrl}/api/providers/gitea/callback`;
const form = useForm<Schema>({
defaultValues: {
clientId: "",
clientSecret: "",
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
},
resolver: zodResolver(Schema),
});
const giteaUrl = form.watch("giteaUrl");
useEffect(() => {
form.reset({
clientId: "",
clientSecret: "",
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
});
}, [form, webhookUrl, isOpen]);
const onSubmit = async (data: Schema) => {
try {
// Send the form data to create the Gitea provider
const result = (await mutateAsync({
clientId: data.clientId,
clientSecret: data.clientSecret,
name: data.name,
redirectUri: data.redirectUri,
giteaUrl: data.giteaUrl,
organizationName: data.organizationName,
})) as unknown as GiteaProviderResponse;
// Check if we have a giteaId from the response
if (!result || !result.giteaId) {
toast.error("Failed to get Gitea ID from response");
return;
}
// Generate OAuth URL using the shared utility
const authUrl = getGiteaOAuthUrl(
result.giteaId,
data.clientId,
data.giteaUrl,
baseUrl,
);
// Open the Gitea OAuth URL
if (authUrl !== "#") {
window.open(authUrl, "_blank");
} else {
toast.error("Configuration Incomplete", {
description: "Please fill in Client ID and Gitea URL first.",
});
}
toast.success("Gitea provider created successfully");
setIsOpen(false);
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(`Error configuring Gitea: ${error.message}`);
} else {
toast.error("An unknown error occurred.");
}
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="default"
className="flex items-center space-x-1 bg-green-700 text-white hover:bg-green-500"
>
<GiteaIcon />
<span>Gitea</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl overflow-y-auto max-h-screen">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Gitea Provider <GiteaIcon className="size-5" />
</DialogTitle>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-gitea"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-1"
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
To integrate your Gitea account, you need to create a new
application in your Gitea settings. Follow these steps:
</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center">
Go to your Gitea settings{" "}
<Link
href={`${giteaUrl}/user/settings/applications`}
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</li>
<li>
Navigate to Applications {"->"} Create new OAuth2
Application
</li>
<li>
Create a new application with the following details:
<ul className="list-disc list-inside ml-4">
<li>Name: Dokploy</li>
<li>
Redirect URI:{" "}
<span className="text-primary">{webhookUrl}</span>{" "}
</li>
</ul>
</li>
<li>
After creating, you'll receive an ID and Secret, copy them
and paste them below.
</li>
</ol>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Random Name eg(my-personal-account)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="giteaUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitea URL</FormLabel>
<FormControl>
<Input placeholder="https://gitea.com/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"
render={({ field }) => (
<FormItem>
<FormLabel>Redirect URI</FormLabel>
<FormControl>
<Input
disabled
placeholder="Random Name eg(my-personal-account)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client Secret"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button isLoading={form.formState.isSubmitting}>
Configure Gitea App
</Button>
</div>
</CardContent>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,296 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { getGiteaOAuthUrl } from "@/utils/gitea-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
giteaUrl: z.string().min(1, "Gitea URL is required"),
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
});
interface Props {
giteaId: string;
}
export const EditGiteaProvider = ({ giteaId }: Props) => {
const router = useRouter();
const [open, setOpen] = useState(false);
const {
data: gitea,
isLoading,
refetch,
} = api.gitea.one.useQuery({ giteaId });
const { mutateAsync, isLoading: isUpdating } = api.gitea.update.useMutation();
const { mutateAsync: testConnection, isLoading: isTesting } =
api.gitea.testConnection.useMutation();
const url = useUrl();
const utils = api.useUtils();
useEffect(() => {
const { connected, error } = router.query;
if (!router.isReady) return;
if (connected) {
toast.success("Successfully connected to Gitea", {
description: "Your Gitea provider has been authorized.",
id: "gitea-connection-success",
});
refetch();
router.replace(
{
pathname: router.pathname,
query: {},
},
undefined,
{ shallow: true },
);
}
if (error) {
toast.error("Gitea Connection Failed", {
description: decodeURIComponent(error as string),
id: "gitea-connection-error",
});
router.replace(
{
pathname: router.pathname,
query: {},
},
undefined,
{ shallow: true },
);
}
}, [router.query, router.isReady, refetch]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
giteaUrl: "https://gitea.com",
clientId: "",
clientSecret: "",
},
});
useEffect(() => {
if (gitea) {
form.reset({
name: gitea.gitProvider?.name || "",
giteaUrl: gitea.giteaUrl || "https://gitea.com",
clientId: gitea.clientId || "",
clientSecret: gitea.clientSecret || "",
});
}
}, [gitea, form]);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
await mutateAsync({
giteaId: giteaId,
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
name: values.name,
giteaUrl: values.giteaUrl,
clientId: values.clientId,
clientSecret: values.clientSecret,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
toast.success("Gitea provider updated successfully");
await refetch();
setOpen(false);
})
.catch(() => {
toast.error("Error updating Gitea provider");
});
};
const handleTestConnection = async () => {
try {
const result = await testConnection({ giteaId });
toast.success("Gitea Connection Verified", {
description: result,
});
} catch (error: any) {
const formValues = form.getValues();
const authUrl =
error.authorizationUrl ||
getGiteaOAuthUrl(
giteaId,
formValues.clientId,
formValues.giteaUrl,
typeof url === "string" ? url : (url as any).url || "",
);
toast.error("Gitea Not Connected", {
description:
error.message || "Please complete the OAuth authorization process.",
action:
authUrl && authUrl !== "#"
? {
label: "Authorize Now",
onClick: () => window.open(authUrl, "_blank"),
}
: undefined,
});
}
};
if (isLoading) {
return (
<Button variant="ghost" size="icon" disabled>
<PenBoxIcon className="h-4 w-4 text-muted-foreground" />
</Button>
);
}
// Function to handle dialog open state
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Gitea Provider</DialogTitle>
<DialogDescription>
Update your Gitea provider details.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My Gitea"
{...field}
autoFocus={false}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="giteaUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitea URL</FormLabel>
<FormControl>
<Input placeholder="https://gitea.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client Secret"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
isLoading={isTesting}
>
Test Connection
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
const formValues = form.getValues();
const authUrl = getGiteaOAuthUrl(
giteaId,
formValues.clientId,
formValues.giteaUrl,
typeof url === "string" ? url : (url as any).url || "",
);
if (authUrl !== "#") {
window.open(authUrl, "_blank");
}
}}
>
Connect to Gitea
</Button>
<Button type="submit" isLoading={isUpdating}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,5 +1,6 @@
import { import {
BitbucketIcon, BitbucketIcon,
GiteaIcon,
GithubIcon, GithubIcon,
GitlabIcon, GitlabIcon,
} from "@/components/icons/data-tools-icons"; } from "@/components/icons/data-tools-icons";
@@ -26,6 +27,8 @@ import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider"; import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider"; import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
import { AddGiteaProvider } from "./gitea/add-gitea-provider";
import { EditGiteaProvider } from "./gitea/edit-gitea-provider";
import { AddGithubProvider } from "./github/add-github-provider"; import { AddGithubProvider } from "./github/add-github-provider";
import { EditGithubProvider } from "./github/edit-github-provider"; import { EditGithubProvider } from "./github/edit-github-provider";
import { AddGitlabProvider } from "./gitlab/add-gitlab-provider"; import { AddGitlabProvider } from "./gitlab/add-gitlab-provider";
@@ -36,19 +39,18 @@ export const ShowGitProviders = () => {
const { mutateAsync, isLoading: isRemoving } = const { mutateAsync, isLoading: isRemoving } =
api.gitProvider.remove.useMutation(); api.gitProvider.remove.useMutation();
const url = useUrl(); const url = useUrl();
const getGitlabUrl = ( const getGitlabUrl = (
clientId: string, clientId: string,
gitlabId: string, gitlabId: string,
gitlabUrl: string, gitlabUrl: string,
) => { ) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`; const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository"; const scope = "api read_user read_repository";
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`; const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
return authUrl; return authUrl;
}; };
return ( return (
<div className="w-full"> <div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto"> <Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
@@ -82,6 +84,7 @@ export const ShowGitProviders = () => {
<AddGithubProvider /> <AddGithubProvider />
<AddGitlabProvider /> <AddGitlabProvider />
<AddBitbucketProvider /> <AddBitbucketProvider />
<AddGiteaProvider />
</div> </div>
</div> </div>
</div> </div>
@@ -97,6 +100,7 @@ export const ShowGitProviders = () => {
<AddGithubProvider /> <AddGithubProvider />
<AddGitlabProvider /> <AddGitlabProvider />
<AddBitbucketProvider /> <AddBitbucketProvider />
<AddGiteaProvider />
</div> </div>
</div> </div>
</div> </div>
@@ -107,13 +111,16 @@ export const ShowGitProviders = () => {
const isGitlab = gitProvider.providerType === "gitlab"; const isGitlab = gitProvider.providerType === "gitlab";
const isBitbucket = const isBitbucket =
gitProvider.providerType === "bitbucket"; gitProvider.providerType === "bitbucket";
const isGitea = gitProvider.providerType === "gitea";
const haveGithubRequirements = const haveGithubRequirements =
gitProvider.providerType === "github" && isGithub &&
gitProvider.github?.githubPrivateKey && gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId && gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId; gitProvider.github?.githubInstallationId;
const haveGitlabRequirements = const haveGitlabRequirements =
isGitlab &&
gitProvider.gitlab?.accessToken && gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken; gitProvider.gitlab?.refreshToken;
@@ -122,18 +129,19 @@ export const ShowGitProviders = () => {
key={gitProvider.gitProviderId} key={gitProvider.gitProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" 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 items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col items-center justify-between"> <div className="flex flex-col items-center justify-between">
<div className="flex gap-2 flex-row items-center"> <div className="flex gap-2 flex-row items-center">
{gitProvider.providerType === "github" && ( {isGithub && (
<GithubIcon className="size-5" /> <GithubIcon className="size-5" />
)} )}
{gitProvider.providerType === "gitlab" && ( {isGitlab && (
<GitlabIcon className="size-5" /> <GitlabIcon className="size-5" />
)} )}
{gitProvider.providerType === "bitbucket" && ( {isBitbucket && (
<BitbucketIcon className="size-5" /> <BitbucketIcon className="size-5" />
)} )}
{isGitea && <GiteaIcon className="size-5" />}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{gitProvider.name} {gitProvider.name}
@@ -194,26 +202,33 @@ export const ShowGitProviders = () => {
</Link> </Link>
</div> </div>
)} )}
{isGithub && haveGithubRequirements && ( {isGithub && haveGithubRequirements && (
<EditGithubProvider <EditGithubProvider
githubId={gitProvider.github.githubId} githubId={gitProvider.github?.githubId}
/> />
)} )}
{isGitlab && ( {isGitlab && (
<EditGitlabProvider <EditGitlabProvider
gitlabId={gitProvider.gitlab.gitlabId} gitlabId={gitProvider.gitlab?.gitlabId}
/> />
)} )}
{isBitbucket && ( {isBitbucket && (
<EditBitbucketProvider <EditBitbucketProvider
bitbucketId={ bitbucketId={
gitProvider.bitbucket.bitbucketId gitProvider.bitbucket?.bitbucketId
} }
/> />
)} )}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
<DialogAction <DialogAction
title="Delete Git Provider" title="Delete Git Provider"
description="Are you sure you want to delete this Git Provider?" description="Are you sure you want to delete this Git Provider?"
@@ -238,7 +253,7 @@ export const ShowGitProviders = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10"
isLoading={isRemoving} isLoading={isRemoving}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />

View File

@@ -663,13 +663,16 @@ export const HandleNotifications = ({ notificationId }: Props) => {
{...field} {...field}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
if (value) { if (value === "") {
field.onChange(undefined);
} else {
const port = Number.parseInt(value); const port = Number.parseInt(value);
if (port > 0 && port < 65536) { if (port > 0 && port < 65536) {
field.onChange(port); field.onChange(port);
} }
} }
}} }}
value={field.value || ""}
type="number" type="number"
/> />
</FormControl> </FormControl>

View File

@@ -25,7 +25,7 @@ export const SecurityAudit = ({ serverId }: Props) => {
enabled: !!serverId, enabled: !!serverId,
}, },
); );
const _utils = api.useUtils();
return ( return (
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -36,10 +36,12 @@ export const SecurityAudit = ({ serverId }: Props) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LockKeyhole className="size-5" /> <LockKeyhole className="size-5" />
<CardTitle className="text-xl"> <CardTitle className="text-xl">
Setup Security Sugestions Setup Security Suggestions
</CardTitle> </CardTitle>
</div> </div>
<CardDescription>Check the security sugestions</CardDescription> <CardDescription>
Check the security suggestions
</CardDescription>
</div> </div>
<Button <Button
isLoading={isRefreshing} isLoading={isRefreshing}
@@ -120,36 +122,36 @@ export const SecurityAudit = ({ serverId }: Props) => {
<div className="grid gap-2.5"> <div className="grid gap-2.5">
<StatusRow <StatusRow
label="Enabled" label="Enabled"
isEnabled={data?.ssh.enabled} isEnabled={data?.ssh?.enabled}
description={ description={
data?.ssh.enabled data?.ssh?.enabled
? "Enabled" ? "Enabled"
: "Not Enabled (SSH should be enabled)" : "Not Enabled (SSH should be enabled)"
} }
/> />
<StatusRow <StatusRow
label="Key Auth" label="Key Auth"
isEnabled={data?.ssh.keyAuth} isEnabled={data?.ssh?.keyAuth}
description={ description={
data?.ssh.keyAuth data?.ssh?.keyAuth
? "Enabled (Recommended)" ? "Enabled (Recommended)"
: "Not Enabled (Key Authentication should be enabled)" : "Not Enabled (Key Authentication should be enabled)"
} }
/> />
<StatusRow <StatusRow
label="Password Auth" label="Password Auth"
isEnabled={data?.ssh.passwordAuth === "no"} isEnabled={data?.ssh?.passwordAuth === "no"}
description={ description={
data?.ssh.passwordAuth === "no" data?.ssh?.passwordAuth === "no"
? "Disabled (Recommended)" ? "Disabled (Recommended)"
: "Enabled (Password Authentication should be disabled)" : "Enabled (Password Authentication should be disabled)"
} }
/> />
<StatusRow <StatusRow
label="Use PAM" label="Use PAM"
isEnabled={data?.ssh.usePam === "no"} isEnabled={data?.ssh?.usePam === "no"}
description={ description={
data?.ssh.usePam === "no" data?.ssh?.usePam === "no"
? "Disabled (Recommended for key-based auth)" ? "Disabled (Recommended for key-based auth)"
: "Enabled (Should be disabled when using key-based auth)" : "Enabled (Should be disabled when using key-based auth)"
} }
@@ -166,9 +168,9 @@ export const SecurityAudit = ({ serverId }: Props) => {
<div className="grid gap-2.5"> <div className="grid gap-2.5">
<StatusRow <StatusRow
label="Installed" label="Installed"
isEnabled={data?.fail2ban.installed} isEnabled={data?.fail2ban?.installed}
description={ description={
data?.fail2ban.installed data?.fail2ban?.installed
? "Installed (Recommended)" ? "Installed (Recommended)"
: "Not Installed (Fail2Ban should be installed for protection against brute force attacks)" : "Not Installed (Fail2Ban should be installed for protection against brute force attacks)"
} }
@@ -176,18 +178,18 @@ export const SecurityAudit = ({ serverId }: Props) => {
<StatusRow <StatusRow
label="Enabled" label="Enabled"
isEnabled={data?.fail2ban.enabled} isEnabled={data?.fail2ban?.enabled}
description={ description={
data?.fail2ban.enabled data?.fail2ban?.enabled
? "Enabled (Recommended)" ? "Enabled (Recommended)"
: "Not Enabled (Fail2Ban service should be enabled)" : "Not Enabled (Fail2Ban service should be enabled)"
} }
/> />
<StatusRow <StatusRow
label="Active" label="Active"
isEnabled={data?.fail2ban.active} isEnabled={data?.fail2ban?.active}
description={ description={
data?.fail2ban.active data?.fail2ban?.active
? "Active (Recommended)" ? "Active (Recommended)"
: "Not Active (Fail2Ban service should be running)" : "Not Active (Fail2Ban service should be running)"
} }
@@ -195,9 +197,9 @@ export const SecurityAudit = ({ serverId }: Props) => {
<StatusRow <StatusRow
label="SSH Protection" label="SSH Protection"
isEnabled={data?.fail2ban.sshEnabled === "true"} isEnabled={data?.fail2ban?.sshEnabled === "true"}
description={ description={
data?.fail2ban.sshEnabled === "true" data?.fail2ban?.sshEnabled === "true"
? "Enabled (Recommended)" ? "Enabled (Recommended)"
: "Not Enabled (SSH protection should be enabled to prevent brute force attacks)" : "Not Enabled (SSH protection should be enabled to prevent brute force attacks)"
} }
@@ -205,11 +207,11 @@ export const SecurityAudit = ({ serverId }: Props) => {
<StatusRow <StatusRow
label="SSH Mode" label="SSH Mode"
isEnabled={data?.fail2ban.sshMode === "aggressive"} isEnabled={data?.fail2ban?.sshMode === "aggressive"}
description={ description={
data?.fail2ban.sshMode === "aggressive" data?.fail2ban?.sshMode === "aggressive"
? "Aggressive Mode (Recommended)" ? "Aggressive Mode (Recommended)"
: `Mode: ${data?.fail2ban.sshMode || "Not Set"} (Aggressive mode recommended for better protection)` : `Mode: ${data?.fail2ban?.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
} }
/> />
</div> </div>

View File

@@ -33,6 +33,7 @@ import { useTranslation } from "next-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions"; import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers"; import { HandleServers } from "./handle-servers";
@@ -42,7 +43,6 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
export const ShowServers = () => { export const ShowServers = () => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");

View File

@@ -112,15 +112,17 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
toast.error("Error generating the SSH Key"); toast.error("Error generating the SSH Key");
}); });
const downloadKey = ( const downloadKey = (content: string, keyType: "private" | "public") => {
content: string,
defaultFilename: string,
keyType: "private" | "public",
) => {
const keyName = form.watch("name"); const keyName = form.watch("name");
const publicKey = form.watch("publicKey");
// Extract algorithm type from public key
const isEd25519 = publicKey.startsWith("ssh-ed25519");
const defaultName = isEd25519 ? "id_ed25519" : "id_rsa";
const filename = keyName const filename = keyName
? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultFilename}` ? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultName}${keyType === "public" ? ".pub" : ""}`
: `${keyType}_${defaultFilename}`; : `${defaultName}${keyType === "public" ? ".pub" : ""}`;
const blob = new Blob([content], { type: "text/plain" }); const blob = new Blob([content], { type: "text/plain" });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -273,7 +275,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
variant="outline" variant="outline"
size="default" size="default"
onClick={() => onClick={() =>
downloadKey(form.watch("privateKey"), "id_rsa", "private") downloadKey(form.watch("privateKey"), "private")
} }
className="flex items-center gap-2" className="flex items-center gap-2"
> >
@@ -287,11 +289,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
variant="outline" variant="outline"
size="default" size="default"
onClick={() => onClick={() =>
downloadKey( downloadKey(form.watch("publicKey"), "public")
form.watch("publicKey"),
"id_rsa.pub",
"public",
)
} }
className="flex items-center gap-2" className="flex items-center gap-2"
> >

View File

@@ -159,9 +159,15 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input <Input
type="number" type="number"
{...field} {...field}
onChange={(e) => onChange={(e) => {
field.onChange(Number(e.target.value)) const value = e.target.value;
} field.onChange(
value === ""
? undefined
: Number(value),
);
}}
value={field.value || ""}
className="w-full dark:bg-black" className="w-full dark:bg-black"
placeholder="e.g. 8080" placeholder="e.g. 8080"
/> />
@@ -185,9 +191,15 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input <Input
type="number" type="number"
{...field} {...field}
onChange={(e) => onChange={(e) => {
field.onChange(Number(e.target.value)) const value = e.target.value;
} field.onChange(
value === ""
? undefined
: Number(value),
);
}}
value={field.value || ""}
className="w-full dark:bg-black" className="w-full dark:bg-black"
placeholder="e.g. 80" placeholder="e.g. 80"
/> />

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -22,7 +23,6 @@ import dynamic from "next/dynamic";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { badgeStateColor } from "../../application/logs/show"; import { badgeStateColor } from "../../application/logs/show";
import { Badge } from "@/components/ui/badge";
export const DockerLogsId = dynamic( export const DockerLogsId = dynamic(
() => () =>

View File

@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { DatabaseIcon, AlertTriangle } from "lucide-react"; import { AlertTriangle, DatabaseIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {

View File

@@ -1,6 +1,6 @@
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources"; import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { RebuildDatabase } from "./rebuild-database"; import { RebuildDatabase } from "./rebuild-database";
interface Props { interface Props {

View File

@@ -238,6 +238,41 @@ export const BitbucketIcon = ({ className }: Props) => {
); );
}; };
export const GiteaIcon = ({ className }: Props) => {
return (
<svg
className={className}
version="1.1"
id="main_outline"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="5.67 143.05 628.65 387.55"
enableBackground="new 0 0 640 640"
>
<g>
<path
id="teabag"
style={{ fill: "#FFFFFF" }}
d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"
/>
<g>
<g>
<path
style={{ fill: "#609926" }}
d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"
/>
<path
style={{ fill: "#609926" }}
d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8C343.2,346.5,335,363.3,326.8,380.1z"
/>
</g>
</g>
</g>
</svg>
);
};
export const DockerIcon = ({ className }: Props) => { export const DockerIcon = ({ className }: Props) => {
return ( return (
<svg <svg

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import type { IUpdateData } from "@dokploy/server/index"; import type { IUpdateData } from "@dokploy/server/index";
import { Download } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import UpdateServer from "../dashboard/settings/web-server/update-server"; import UpdateServer from "../dashboard/settings/web-server/update-server";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Download } from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,

View File

@@ -37,7 +37,9 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
)} )}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
{_index + 1 < list.length && <BreadcrumbSeparator className="block" />} {_index + 1 < list.length && (
<BreadcrumbSeparator className="block" />
)}
</Fragment> </Fragment>
))} ))}
</BreadcrumbList> </BreadcrumbList>

View File

@@ -3,18 +3,18 @@ import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml"; import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language"; import { StreamLanguage } from "@codemirror/language";
import {
type Completion,
type CompletionContext,
type CompletionResult,
autocompletion,
} from "@codemirror/autocomplete";
import { properties } from "@codemirror/legacy-modes/mode/properties"; import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell"; import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import {
autocompletion,
type CompletionContext,
type CompletionResult,
type Completion,
} from "@codemirror/autocomplete";
// Docker Compose completion options // Docker Compose completion options
const dockerComposeServices = [ const dockerComposeServices = [

View File

@@ -1,9 +1,9 @@
import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import type * as React from "react";
import { DayPicker } from "react-day-picker"; import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type CalendarProps = React.ComponentProps<typeof DayPicker>; export type CalendarProps = React.ComponentProps<typeof DayPicker>;

View File

@@ -39,7 +39,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
className={cn("text-left", className)} className={cn("text-left", className)}
ref={ref} ref={ref}
{...props} {...props}
value={props.value === undefined || props.value === "" ? "" : String(props.value)} value={props.value === undefined ? undefined : String(props.value)}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
if (value === "") { if (value === "") {
@@ -60,21 +60,6 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
} }
} }
}} }}
onBlur={(e) => {
// If input is empty, make 0 when focus is lost
if (e.target.value === "") {
const syntheticEvent = {
...e,
target: {
...e.target,
value: "0",
},
};
props.onChange?.(
syntheticEvent as unknown as React.ChangeEvent<HTMLInputElement>,
);
}
}}
/> />
); );
}, },

View File

@@ -0,0 +1,31 @@
ALTER TYPE "public"."sourceType" ADD VALUE 'gitea' BEFORE 'drop';--> statement-breakpoint
ALTER TYPE "public"."sourceTypeCompose" ADD VALUE 'gitea' BEFORE 'raw';--> statement-breakpoint
ALTER TYPE "public"."gitProviderType" ADD VALUE 'gitea';--> statement-breakpoint
CREATE TABLE "gitea" (
"giteaId" text PRIMARY KEY NOT NULL,
"giteaUrl" text DEFAULT 'https://gitea.com' NOT NULL,
"redirect_uri" text,
"client_id" text,
"client_secret" text,
"gitProviderId" text NOT NULL,
"gitea_username" text,
"access_token" text,
"refresh_token" text,
"expires_at" integer,
"scopes" text DEFAULT 'repo,repo:status,read:user,read:org',
"last_authenticated_at" integer
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaProjectId" integer;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaRepository" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaOwner" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaBranch" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaBuildPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaRepository" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaOwner" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaBranch" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaId" text;--> statement-breakpoint
ALTER TABLE "gitea" ADD CONSTRAINT "gitea_gitProviderId_git_provider_gitProviderId_fk" FOREIGN KEY ("gitProviderId") REFERENCES "public"."git_provider"("gitProviderId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_giteaId_gitea_giteaId_fk" FOREIGN KEY ("giteaId") REFERENCES "public"."gitea"("giteaId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "compose" ADD CONSTRAINT "compose_giteaId_gitea_giteaId_fk" FOREIGN KEY ("giteaId") REFERENCES "public"."gitea"("giteaId") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" DROP COLUMN "giteaProjectId";

View File

@@ -0,0 +1 @@
ALTER TABLE "gitea" DROP COLUMN "gitea_username";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "backup" ADD COLUMN "userId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."databaseType" ADD VALUE 'web-server';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -554,6 +554,41 @@
"when": 1742112194375, "when": 1742112194375,
"tag": "0078_uneven_omega_sentinel", "tag": "0078_uneven_omega_sentinel",
"breakpoints": true "breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1742281690186,
"tag": "0079_bizarre_wendell_rand",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1743280866402,
"tag": "0080_sleepy_sinister_six",
"breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1743281254393,
"tag": "0081_lovely_mentallo",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1743287689974,
"tag": "0082_clean_mandarin",
"breakpoints": true
},
{
"idx": 83,
"version": "7",
"when": 1743288371413,
"tag": "0083_parallel_stranger",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,552 @@
{
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1713262741218,
"tag": "0000_reflective_puck",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1713761637676,
"tag": "0001_striped_tattoo",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1713763492341,
"tag": "0002_ambiguous_carlie_cooper",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1713947141424,
"tag": "0003_square_lightspeed",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1714004732716,
"tag": "0004_nice_tenebrous",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1715551130605,
"tag": "0005_cute_terror",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1715563165991,
"tag": "0006_oval_jimmy_woo",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1715563497100,
"tag": "0007_cute_guardsmen",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1715564143641,
"tag": "0008_lazy_sage",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1715564774423,
"tag": "0009_majestic_spencer_smythe",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1715574037832,
"tag": "0010_lean_black_widow",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1715574230599,
"tag": "0011_petite_calypso",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1716015716708,
"tag": "0012_chubby_umar",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1716076179443,
"tag": "0013_blushing_starjammers",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1716715367982,
"tag": "0014_same_hammerhead",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1717564517104,
"tag": "0015_fearless_callisto",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1719109196484,
"tag": "0016_chunky_leopardon",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1719547174326,
"tag": "0017_minor_post",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1719928377858,
"tag": "0018_careful_killmonger",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1721110706912,
"tag": "0019_heavy_freak",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1721363861686,
"tag": "0020_fantastic_slapstick",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1721370423752,
"tag": "0021_premium_sebastian_shaw",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1721531163852,
"tag": "0022_warm_colonel_america",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1721542782659,
"tag": "0023_icy_maverick",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1721603595092,
"tag": "0024_dapper_supernaut",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1721633853118,
"tag": "0025_lying_mephisto",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1721979220929,
"tag": "0026_known_dormammu",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1722445099203,
"tag": "0027_red_lady_bullseye",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1722503439951,
"tag": "0028_jittery_eternity",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1722578386823,
"tag": "0029_colossal_zodiak",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1723608499147,
"tag": "0030_little_kabuki",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1723701656243,
"tag": "0031_steep_vulture",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1723705257806,
"tag": "0032_flashy_shadow_king",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1725250322137,
"tag": "0033_white_hawkeye",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1725256397019,
"tag": "0034_aspiring_secret_warriors",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1725429324584,
"tag": "0035_cool_gravity",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1725519351871,
"tag": "0036_tired_ronan",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1726988289562,
"tag": "0037_legal_namor",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1727942090102,
"tag": "0038_rapid_landau",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1728021127765,
"tag": "0039_many_tiger_shark",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1728780577084,
"tag": "0040_graceful_wolfsbane",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1729667438853,
"tag": "0041_huge_bruce_banner",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1729984439862,
"tag": "0042_fancy_havok",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1731873965888,
"tag": "0043_closed_naoko",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1731875539532,
"tag": "0044_sour_true_believers",
"breakpoints": true
},
{
"idx": 45,
"version": "6",
"when": 1732644181718,
"tag": "0045_smiling_blur",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1732851191048,
"tag": "0046_purple_sleeper",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1733599090582,
"tag": "0047_tidy_revanche",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1733599163710,
"tag": "0048_flat_expediter",
"breakpoints": true
},
{
"idx": 49,
"version": "6",
"when": 1733628762978,
"tag": "0049_dark_leopardon",
"breakpoints": true
},
{
"idx": 50,
"version": "6",
"when": 1733889104203,
"tag": "0050_nappy_wrecker",
"breakpoints": true
},
{
"idx": 51,
"version": "6",
"when": 1734241482851,
"tag": "0051_hard_gorgon",
"breakpoints": true
},
{
"idx": 52,
"version": "6",
"when": 1734809337308,
"tag": "0052_bumpy_luckman",
"breakpoints": true
},
{
"idx": 53,
"version": "6",
"when": 1735118844878,
"tag": "0053_broken_kulan_gath",
"breakpoints": true
},
{
"idx": 54,
"version": "6",
"when": 1736669421560,
"tag": "0054_nervous_spencer_smythe",
"breakpoints": true
},
{
"idx": 55,
"version": "6",
"when": 1736669623831,
"tag": "0055_next_serpent_society",
"breakpoints": true
},
{
"idx": 56,
"version": "6",
"when": 1736789918294,
"tag": "0056_majestic_skaar",
"breakpoints": true
},
{
"idx": 57,
"version": "6",
"when": 1737306063563,
"tag": "0057_tricky_living_tribunal",
"breakpoints": true
},
{
"idx": 58,
"version": "6",
"when": 1737612903012,
"tag": "0058_brown_sharon_carter",
"breakpoints": true
},
{
"idx": 59,
"version": "6",
"when": 1737615160768,
"tag": "0059_striped_bill_hollister",
"breakpoints": true
},
{
"idx": 60,
"version": "6",
"when": 1737929896838,
"tag": "0060_disable-aggressive-cache",
"breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1738481304953,
"tag": "0061_many_molten_man",
"breakpoints": true
},
{
"idx": 62,
"version": "7",
"when": 1738482795112,
"tag": "0062_slippery_white_tiger",
"breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1738522845992,
"tag": "0063_panoramic_dreadnoughts",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1738564387043,
"tag": "0064_previous_agent_brand",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1739087857244,
"tag": "0065_daily_zaladane",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1739426913392,
"tag": "0066_yielding_echo",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1740892043121,
"tag": "0067_condemned_sugar_man",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1740897756774,
"tag": "0068_complex_rhino",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1741152916611,
"tag": "0069_legal_bill_hollister",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1741322697251,
"tag": "0070_useful_serpent_society",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1741559743256,
"tag": "0071_flimsy_plazm",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1741593124105,
"tag": "0072_low_redwing",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1741645208694,
"tag": "0073_dark_tigra",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1741673569715,
"tag": "0074_military_miss_america",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1742018928109,
"tag": "0075_wild_xorn",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1742237840762,
"tag": "0076_tough_iron_patriot",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1742238314349,
"tag": "0077_mature_tomorrow_man",
"breakpoints": true
}
]
}

View File

@@ -1,32 +0,0 @@
// middleware.ts
import { verifyRequestOrigin } from "lucia";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest): Promise<NextResponse> {
if (request.method === "GET") {
return NextResponse.next();
}
const originHeader = request.headers.get("Origin");
const hostHeader = request.headers.get("Host");
if (
!originHeader ||
!hostHeader ||
!verifyRequestOrigin(originHeader, [hostHeader])
) {
return new NextResponse(null, {
status: 403,
});
}
return NextResponse.next();
}
export const config = {
matcher: [
// Don't handle HMR requests for the dev server we rewrite to
"/settings",
"/dashboard/(.*)",
"/invitation",
],
};

View File

@@ -5,23 +5,23 @@
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
transpilePackages: ["@dokploy/server"], transpilePackages: ["@dokploy/server"],
/** /**
* If you are using `appDir` then you must comment the below `i18n` config out. * If you are using `appDir` then you must comment the below `i18n` config out.
* *
* @see https://github.com/vercel/next.js/issues/41980 * @see https://github.com/vercel/next.js/issues/41980
*/ */
i18n: { i18n: {
locales: ["en"], locales: ["en"],
defaultLocale: "en", defaultLocale: "en",
}, },
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.20.5", "version": "v0.21.2",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -53,7 +53,6 @@
"@dokploy/trpc-openapi": "0.0.4", "@dokploy/trpc-openapi": "0.0.4",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@lucia-auth/adapter-drizzle": "1.0.7",
"@octokit/auth-app": "^6.0.4", "@octokit/auth-app": "^6.0.4",
"@octokit/webhooks": "^13.2.7", "@octokit/webhooks": "^13.2.7",
"@radix-ui/react-accordion": "1.1.2", "@radix-ui/react-accordion": "1.1.2",
@@ -113,11 +112,10 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"lucia": "^3.0.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"micromatch": "4.0.8", "micromatch": "4.0.8",
"nanoid": "3", "nanoid": "3",
"next": "^15.0.1", "next": "^15.2.4",
"next-i18next": "^15.3.1", "next-i18next": "^15.3.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"node-os-utils": "1.3.7", "node-os-utils": "1.3.7",
@@ -152,7 +150,8 @@
"ws": "8.16.0", "ws": "8.16.0",
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4", "zod": "^3.23.4",
"zod-form-data": "^2.0.2" "zod-form-data": "^2.0.2",
"toml": "3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.5", "@types/adm-zip": "^0.5.5",

View File

@@ -84,6 +84,33 @@ export default async function handler(
res.status(301).json({ message: "Branch Not Match" }); res.status(301).json({ message: "Branch Not Match" });
return; return;
} }
const provider = getProviderByHeader(req.headers);
let normalizedCommits: string[] = [];
if (provider === "github") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "gitlab") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "gitea") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
} else if (sourceType === "gitlab") { } else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body); const branchName = extractBranchName(req.headers, req.body);
@@ -128,6 +155,27 @@ export default async function handler(
res.status(301).json({ message: "Watch Paths Not Match" }); res.status(301).json({ message: "Watch Paths Not Match" });
return; return;
} }
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
if (!branchName || branchName !== application.giteaBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} }
try { try {
@@ -280,6 +328,26 @@ export const extractBranchName = (headers: any, body: any) => {
return null; return null;
}; };
export const getProviderByHeader = (headers: any) => {
if (headers["x-github-event"]) {
return "github";
}
if (headers["x-gitea-event"]) {
return "gitea";
}
if (headers["x-gitlab-event"]) {
return "gitlab";
}
if (headers["x-event-key"]?.includes("repo:push")) {
return "bitbucket";
}
return null;
};
export const extractCommitedPaths = async ( export const extractCommitedPaths = async (
body: any, body: any,
bitbucketUsername: string | null, bitbucketUsername: string | null,

View File

@@ -8,9 +8,10 @@ import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { import {
extractBranchName, extractBranchName,
extractCommitedPaths,
extractCommitMessage, extractCommitMessage,
extractCommitedPaths,
extractHash, extractHash,
getProviderByHeader,
} from "../[refreshToken]"; } from "../[refreshToken]";
export default async function handler( export default async function handler(
@@ -91,12 +92,6 @@ export default async function handler(
res.status(301).json({ message: "Branch Not Match" }); res.status(301).json({ message: "Branch Not Match" });
return; return;
} }
} else if (sourceType === "git") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== composeResult.customGitBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
const commitedPaths = await extractCommitedPaths( const commitedPaths = await extractCommitedPaths(
req.body, req.body,
@@ -104,6 +99,7 @@ export default async function handler(
composeResult.bitbucket?.appPassword || "", composeResult.bitbucket?.appPassword || "",
composeResult.bitbucketRepository || "", composeResult.bitbucketRepository || "",
); );
const shouldDeployPaths = shouldDeploy( const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths, composeResult.watchPaths,
commitedPaths, commitedPaths,
@@ -113,6 +109,59 @@ export default async function handler(
res.status(301).json({ message: "Watch Paths Not Match" }); res.status(301).json({ message: "Watch Paths Not Match" });
return; return;
} }
} else if (sourceType === "git") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== composeResult.customGitBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
const provider = getProviderByHeader(req.headers);
let normalizedCommits: string[] = [];
if (provider === "github") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "gitlab") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "gitea") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
if (!branchName || branchName !== composeResult.giteaBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} }
try { try {

View File

@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { findGitea, redirectWithError } from "./helper";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const { giteaId } = req.query;
if (!giteaId || Array.isArray(giteaId)) {
return res.status(400).json({ error: "Invalid Gitea provider ID" });
}
const gitea = await findGitea(giteaId as string);
if (!gitea || !gitea.clientId || !gitea.redirectUri) {
return redirectWithError(res, "Incomplete OAuth configuration");
}
// Generate the Gitea authorization URL
const authorizationUrl = new URL(`${gitea.giteaUrl}/login/oauth/authorize`);
authorizationUrl.searchParams.append("client_id", gitea.clientId as string);
authorizationUrl.searchParams.append("response_type", "code");
authorizationUrl.searchParams.append(
"redirect_uri",
gitea.redirectUri as string,
);
authorizationUrl.searchParams.append("scope", "read:user repo");
authorizationUrl.searchParams.append("state", giteaId as string);
// Redirect user to Gitea authorization URL
return res.redirect(307, authorizationUrl.toString());
} catch (error) {
console.error("Error initiating Gitea OAuth flow:", error);
return res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -0,0 +1,93 @@
import { updateGitea } from "@dokploy/server";
import type { NextApiRequest, NextApiResponse } from "next";
import { type Gitea, findGitea, redirectWithError } from "./helper";
// Helper to parse the state parameter
const parseState = (state: string): string | null => {
try {
const stateObj =
state.startsWith("{") && state.endsWith("}") ? JSON.parse(state) : {};
return stateObj.giteaId || state || null;
} catch {
return null;
}
};
// Helper to fetch access token from Gitea
const fetchAccessToken = async (gitea: Gitea, code: string) => {
const response = await fetch(`${gitea.giteaUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: gitea.clientId as string,
client_secret: gitea.clientSecret as string,
code,
grant_type: "authorization_code",
redirect_uri: gitea.redirectUri || "",
}),
});
const responseText = await response.text();
return response.ok
? JSON.parse(responseText)
: { error: "Token exchange failed", responseText };
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { code, state } = req.query;
if (!code || Array.isArray(code) || !state || Array.isArray(state)) {
return redirectWithError(
res,
"Invalid authorization code or state parameter",
);
}
const giteaId = parseState(state as string);
if (!giteaId) return redirectWithError(res, "Invalid state format");
const gitea = await findGitea(giteaId);
if (!gitea) return redirectWithError(res, "Failed to find Gitea provider");
// Fetch the access token from Gitea
const result = await fetchAccessToken(gitea, code as string);
if (result.error) {
console.error("Token exchange failed:", result);
return redirectWithError(res, result.error);
}
if (!result.access_token) {
console.error("Missing access token:", result);
return redirectWithError(res, "No access token received");
}
const expiresAt = result.expires_in
? Math.floor(Date.now() / 1000) + result.expires_in
: null;
try {
await updateGitea(gitea.giteaId, {
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt,
...(result.organizationName
? { organizationName: result.organizationName }
: {}),
});
return res.redirect(
307,
"/dashboard/settings/git-providers?connected=true",
);
} catch (updateError) {
console.error("Failed to update Gitea provider:", updateError);
return redirectWithError(res, "Failed to store access token");
}
}

View File

@@ -0,0 +1,39 @@
import { findGiteaById } from "@dokploy/server";
import type { NextApiResponse } from "next";
export interface Gitea {
giteaId: string;
gitProviderId: string;
redirectUri: string | null;
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
giteaUrl: string;
clientId: string | null;
clientSecret: string | null;
organizationName?: string;
gitProvider: {
name: string;
gitProviderId: string;
providerType: "github" | "gitlab" | "bitbucket" | "gitea";
createdAt: string;
organizationId: string;
};
}
export const findGitea = async (giteaId: string): Promise<Gitea | null> => {
try {
const gitea = await findGiteaById(giteaId);
return gitea;
} catch (findError) {
console.error("Error finding Gitea provider:", findError);
return null;
}
};
export const redirectWithError = (res: NextApiResponse, error: string) => {
return res.redirect(
307,
`/dashboard/settings/git-providers?error=${encodeURIComponent(error)}`,
);
};

View File

@@ -34,6 +34,15 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
} from "@/components/ui/command"; } from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -47,6 +56,13 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -64,8 +80,8 @@ import {
Loader2, Loader2,
PlusIcon, PlusIcon,
Search, Search,
X,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import type { import type {
GetServerSidePropsContext, GetServerSidePropsContext,
@@ -73,25 +89,10 @@ import type {
} from "next"; } from "next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { type ReactElement, useMemo, useState, useEffect } from "react"; import { type ReactElement, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import superjson from "superjson"; import superjson from "superjson";
import { import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type Services = { export type Services = {
appName: string; appName: string;
@@ -553,7 +554,7 @@ const Project = (
</CardTitle> </CardTitle>
<CardDescription>{data?.description}</CardDescription> <CardDescription>{data?.description}</CardDescription>
</CardHeader> </CardHeader>
{(auth?.role === "owner" || auth?.canCreateServices) && ( <div className="flex flex-row gap-4 flex-wrap justify-between items-center">
<div className="flex flex-row gap-4 flex-wrap"> <div className="flex flex-row gap-4 flex-wrap">
<ProjectEnvironment projectId={projectId}> <ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button> <Button variant="outline">Project Environment</Button>
@@ -569,7 +570,7 @@ const Project = (
className="w-[200px] space-y-2" className="w-[200px] space-y-2"
align="end" align="end"
> >
<DropdownMenuLabel className="text-sm font-normal "> <DropdownMenuLabel className="text-sm font-normal">
Actions Actions
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -593,7 +594,7 @@ const Project = (
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
)} </div>
</div> </div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]"> <CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
{isLoading ? ( {isLoading ? (
@@ -670,20 +671,27 @@ const Project = (
</DialogAction> </DialogAction>
{(auth?.role === "owner" || {(auth?.role === "owner" ||
auth?.canDeleteServices) && ( auth?.canDeleteServices) && (
<DialogAction <>
title="Delete Services" <DialogAction
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`} title="Delete Services"
type="destructive" description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
onClick={handleBulkDelete} type="destructive"
> onClick={handleBulkDelete}
<Button
variant="ghost"
className="w-full justify-start text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Button
Delete variant="ghost"
</Button> className="w-full justify-start text-destructive"
</DialogAction> >
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</>
)} )}
<Dialog <Dialog

View File

@@ -1,3 +1,4 @@
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment"; import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command"; import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
@@ -47,7 +48,6 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react"; import { type ReactElement, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import superjson from "superjson"; import superjson from "superjson";
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
type TabState = type TabState =
| "projects" | "projects"

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