Compare commits

...

234 Commits

Author SHA1 Message Date
Mauricio Siu
038df9c8a7 Merge pull request #897 from Dokploy/canary
v0.15.0
2024-12-15 21:49:15 -06:00
Mauricio Siu
829aa2a63c Merge pull request #901 from Dokploy/feat/security
Feat: add remote server audit
2024-12-15 21:38:37 -06:00
Mauricio Siu
91e90fc379 Merge pull request #900 from drudge/canary
chore: clean up page titles
2024-12-15 21:38:28 -06:00
Mauricio Siu
a1e13ee964 refactor: set audit 2024-12-15 21:37:33 -06:00
Mauricio Siu
341af1bd07 feat: add loader to enhance ux/ui 2024-12-15 21:33:10 -06:00
Nicholas Penree
8a274d10eb chore: clean up page titles 2024-12-15 22:21:10 -05:00
Mauricio Siu
6c586f9606 refactor: add experimental advice 2024-12-15 21:18:23 -06:00
Mauricio Siu
dcb1ea37c3 feat: add server audit 2024-12-15 21:16:14 -06:00
Mauricio Siu
58c2ceb355 feat: add security audit 2024-12-15 21:13:37 -06:00
Mauricio Siu
beae03b53d Merge pull request #898 from drudge/canary
feat(server): monospace script editor
2024-12-15 20:48:38 -06:00
Nicholas Penree
55ec25f5e8 feat(server): edit copy on script editor 2024-12-15 21:24:27 -05:00
Nicholas Penree
9382acb40c feat(server): monospace script editor 2024-12-15 21:16:39 -05:00
Mauricio Siu
c0acdc5df1 refactor: add audit 2024-12-15 19:55:45 -06:00
Mauricio Siu
413536a336 chore: bump version 2024-12-15 19:30:05 -06:00
Mauricio Siu
190f45b3a8 Merge pull request #857 from 190km/new-logs
feat(logs): new railway inspired logs
2024-12-15 19:29:26 -06:00
Mauricio Siu
e6c242a064 refactor: set logs no found when the search is empty 2024-12-15 19:24:50 -06:00
Mauricio Siu
c2fe1eed01 fix: remote server search add long buffering 2024-12-15 19:18:41 -06:00
Mauricio Siu
676082fc5b Merge branch 'canary' into new-logs 2024-12-15 19:01:26 -06:00
Mauricio Siu
b676b1a2de Merge pull request #894 from Dokploy/feat/improve-cloud
refactor: remove unused files
2024-12-15 18:50:46 -06:00
Mauricio Siu
5885712c6a Merge pull request #896 from drudge/copy-delete
feat(delete): add quick copy to resource to clipboard in delete modal
2024-12-15 18:49:51 -06:00
Mauricio Siu
afedeede16 feat: add self remove accounts 2024-12-15 18:45:02 -06:00
Nicholas Penree
5f09018199 feat(delete): add quick copy to resource to clipboard in delete modal 2024-12-15 19:17:46 -05:00
Mauricio Siu
9d37876bc4 feat: add reset onboarding 2024-12-15 15:03:25 -06:00
Mauricio Siu
775107ec24 feat: add dokploy cloud modal 2024-12-15 14:54:38 -06:00
Mauricio Siu
5f297fd984 feat: add react tour 2024-12-15 02:14:43 -06:00
Mauricio Siu
86aba9ce3e refactor: remove cols 2024-12-14 23:12:21 -06:00
Mauricio Siu
c6e512bec1 refåctor: remove files 2024-12-14 23:06:54 -06:00
Mauricio Siu
fc2b0abdb1 Revert "refactor: remove unsued files"
This reverts commit d20f86ffe1.
2024-12-14 23:05:19 -06:00
Mauricio Siu
d20f86ffe1 refactor: remove unsued files 2024-12-14 22:46:01 -06:00
190km
1157e08aa1 fix: log line came out of the div 2024-12-15 04:21:38 +01:00
Mauricio Siu
e643255a67 Merge pull request #893 from Dokploy/510-fractional-cpu-reservation-and-limit
refactor: remove calculation and pass resources as the docker api expect
2024-12-14 20:40:16 -06:00
Mauricio Siu
7521bc8297 refactor: remove calculation and pass resources as the docker api expect 2024-12-14 20:38:33 -06:00
Mauricio Siu
a63981fa15 Merge pull request #892 from Dokploy/680-redirect-to-the-deployment-logs
feat: redirect to deployments when click on deploy
2024-12-14 20:30:15 -06:00
Mauricio Siu
ea0f797d0f feat: redirect to deployments when click on deploy 2024-12-14 20:28:28 -06:00
Mauricio Siu
181a2ca3c9 Merge pull request #891 from Dokploy/769-elasticsearch-new-db-template
769 elasticsearch new db template
2024-12-14 20:18:49 -06:00
Mauricio Siu
3fe057c7f8 refactor: set version 2024-12-14 20:17:38 -06:00
Mauricio Siu
1e834ed1d9 feat: add elastic search 2024-12-14 20:17:20 -06:00
Mauricio Siu
9f84545fc7 Merge pull request #890 from Dokploy/889-search-for-templates-only-takes-into-account-the-template-title-missing-description-matches
refactor: add description on search
2024-12-14 12:38:05 -06:00
Mauricio Siu
690a2e7467 refactor: add description on search 2024-12-14 12:37:50 -06:00
190km
995d9004f3 style: fix text light theme 2024-12-14 18:23:00 +01:00
Mauricio Siu
0a3ab7ceac Merge pull request #868 from DJKnaeckebrot/feat/page-titles
feat: add page titles
2024-12-14 02:28:31 -06:00
Mauricio Siu
2fd4d580d5 Merge pull request #880 from DJKnaeckebrot/feature/penpot-template
feat: add penpot template
2024-12-14 02:27:01 -06:00
Mauricio Siu
33e2fa3ce3 Merge branch 'canary' into feature/penpot-template 2024-12-14 02:26:35 -06:00
Mauricio Siu
d320847da4 refactor: remove traefik 2024-12-14 02:25:35 -06:00
Mauricio Siu
9e84bf324e Merge pull request #875 from DJKnaeckebrot/feature/huly-template
feat: add huly template
2024-12-14 01:48:11 -06:00
Mauricio Siu
db469e60ad Merge branch 'canary' into feature/huly-template 2024-12-14 01:37:13 -06:00
Mauricio Siu
0f949b3273 Merge pull request #874 from DJKnaeckebrot/feature/langflow-template
feat: add langflow template
2024-12-14 01:36:23 -06:00
Mauricio Siu
166b65c50e refactor: add env variables 2024-12-14 01:34:05 -06:00
Mauricio Siu
274c65cbcd refactor: add unsend 2024-12-14 01:29:39 -06:00
Mauricio Siu
b538a632d9 Merge branch 'canary' into feature/langflow-template 2024-12-14 01:28:38 -06:00
Mauricio Siu
765c6442cb Merge pull request #869 from DJKnaeckebrot/feature/unsend-dev-template
feat: add unsend template
2024-12-14 01:24:09 -06:00
Mauricio Siu
115ed7e7bf refactor: remove is loading false 2024-12-14 01:17:13 -06:00
190km
0644842305 style: selects width 2024-12-14 03:00:05 +01:00
190km
a9c62b47ef style: selects wididth 2024-12-14 02:58:21 +01:00
190km
138650d561 feat: improved deployment view scroll & style 2024-12-14 01:30:36 +01:00
190km
280be5c9df style: fixed select sizes 2024-12-13 21:13:10 +01:00
190km
7726fa6112 style: make selects responsive 2024-12-13 20:29:26 +01:00
190km
c71d12fd06 feat: added info possibilities & debug more debug possibilities 2024-12-13 20:21:00 +01:00
usopp
3df3d187e4 feat: added deployment loader & lines count 2024-12-13 19:41:02 +01:00
djknaeckebrot
8ce9db8dd6 feat: add penpot template 2024-12-13 19:40:49 +01:00
190km
6773458da3 fix: text came out of the parent div 2024-12-13 18:29:18 +01:00
Nicholas Penree
e5d5a98bab feat(logs): preserve whitespace in log line 2024-12-13 09:15:56 -05:00
Nicholas Penree
4311ba93f3 chore: lint/typecheck 2024-12-13 09:00:17 -05:00
djknaeckebrot
e0b596ec76 chore: set static minio version 2024-12-13 14:14:22 +01:00
djknaeckebrot
379ba20930 feat: add huly.io template 2024-12-13 13:55:57 +01:00
djknaeckebrot
236e511adc feat: add langflow template 2024-12-13 12:02:01 +01:00
djknaeckebrot
0b37e171c5 chore: add fixed versions 2024-12-13 09:20:23 +01:00
Mauricio Siu
1df1e7b50b Merge pull request #870 from drudge/setup
fix(setup/validate): arm64 build fixes, improved validation
2024-12-13 01:07:13 -06:00
Mauricio Siu
f15a5bc22d Update apps/dokploy/templates/unsend/index.ts 2024-12-13 00:50:12 -06:00
Mauricio Siu
469871d383 Update apps/dokploy/templates/unsend/docker-compose.yml 2024-12-13 00:50:08 -06:00
Mauricio Siu
e22b6ab9be Update apps/dokploy/templates/unsend/docker-compose.yml 2024-12-13 00:50:04 -06:00
Mauricio Siu
b01b05077d Update apps/dokploy/templates/unsend/docker-compose.yml 2024-12-13 00:49:58 -06:00
Mauricio Siu
22122361ba Update apps/dokploy/templates/unsend/docker-compose.yml 2024-12-13 00:49:54 -06:00
Mauricio Siu
87c1ce68b9 Update apps/dokploy/templates/unsend/docker-compose.yml 2024-12-13 00:49:49 -06:00
Mauricio Siu
4c8619677b Update apps/dokploy/templates/unsend/docker-compose.yml 2024-12-13 00:49:45 -06:00
Mauricio Siu
7f705e31d3 Merge pull request #867 from DJKnaeckebrot/feat/add-vscode-settings-for-same-formatting
format: setup .vscode folder with biome.js for consistent format and spacing
2024-12-13 00:39:18 -06:00
Mauricio Siu
20432ebc3f Merge pull request #856 from DJKnaeckebrot/feat/project-search
feat: add global search command
2024-12-13 00:38:07 -06:00
Nicholas Penree
a51a7a82d2 feat(setup): remove debconf warnings during setup 2024-12-12 23:58:56 -05:00
Nicholas Penree
5ba19686c8 feat(setup): align pass/fail icons at the end 2024-12-12 23:57:54 -05:00
Nicholas Penree
22a2e64563 feat(logs): tooltip improvements (break out, no delay) 2024-12-12 23:12:13 -05:00
190km
37ee89e6ab fix: debug value in select 2024-12-13 00:53:58 +01:00
190km
cb487b8be0 feat: added debug log type & noTimestamp props for TerminalLine 2024-12-12 21:53:10 +01:00
DJKnaeckebrot
26f8719e5f fix: wrong port publishing 2024-12-12 21:41:57 +01:00
190km
3bc1bd5b15 feat: added more log success filter 2024-12-12 21:10:19 +01:00
190km
ee622b1ba0 feat: added new logs styling in deployments views 2024-12-12 21:00:17 +01:00
190km
fe088bad3b feat: add loading spinner when logs are being loaded 2024-12-12 19:54:44 +01:00
usopp
d374f5eedf fix: no time found block same width as the timestamp ones 2024-12-12 19:28:16 +01:00
Nicholas Penree
1c498ee2d2 fix(setup/validate): arm64 build fixes, improved validation 2024-12-12 12:11:30 -05:00
djknaeckebrot
d7e5eb6dfd feat: add unsend template 2024-12-12 16:46:58 +01:00
djknaeckebrot
f71e04eaaa feat: add page titles 2024-12-12 12:56:55 +01:00
djknaeckebrot
fc9808e295 feat: added .vscode folder
Added settings.json to use biome.js formatter by default
Added extentions.json to recommend to install biome.js

This all is to keep spacing the same across different contributors and to make it easier for people to get started contributing
2024-12-12 11:56:57 +01:00
djknaeckebrot
bc2a286e1d fix: close modal after selection of item 2024-12-12 08:48:23 +01:00
djknaeckebrot
6c582eb91d chore: close when project or app is selected
chore: better closing checks
fix: url for projects
2024-12-12 08:43:41 +01:00
djknaeckebrot
e3b2a401a7 fix: resolve issue with same names 2024-12-12 08:39:29 +01:00
djknaeckebrot
6c55143e96 chore: remove debug logging 2024-12-12 08:33:23 +01:00
djknaeckebrot
19a0550b32 style: change status indication 2024-12-12 08:32:18 +01:00
Mauricio Siu
bb31bef8bc Merge pull request #865 from drudge/config-code-editor
feat(docker): use code editor when displaying container config
2024-12-11 23:31:21 -06:00
Mauricio Siu
abc606d8d9 Merge pull request #864 from mezotv/dynamic-timestamp
feat(dynamic timestamp): add dynamic discord timestamps
2024-12-11 23:28:47 -06:00
Mauricio Siu
749dd03fe6 Merge pull request #859 from kawws/feature/github-app
feat: improve github app validation
2024-12-11 23:25:27 -06:00
Mauricio Siu
858d7e5c11 Merge pull request #863 from 190km/italian-lang
feat(i18n): add italian language support
2024-12-11 23:12:31 -06:00
Mauricio Siu
079b7b8e72 Merge branch 'canary' into italian-lang 2024-12-11 23:11:22 -06:00
Nicholas Penree
179f3818f0 feat(docker): use code editor when displaying container config 2024-12-11 22:53:10 -05:00
Nicholas Penree
8546031df0 feat(logs): lint 2024-12-11 19:32:34 -05:00
Nicholas Penree
16ca198eb4 feat(logs): better download file names 2024-12-11 19:31:37 -05:00
Nicholas Penree
9b5b452d90 Merge pull request #2 from drudge/log-test
feat(logs): improvements based on feedback
2024-12-11 19:23:45 -05:00
Nicholas Penree
2fa6f3bfa6 feat(logs): lint 2024-12-11 19:20:30 -05:00
Nicholas Penree
42f3105f69 feat(logs): improvements based on feedback 2024-12-11 19:13:53 -05:00
Dominik Koch
a08ba7e8b5 feat(dynamic timestamp): add dynamic discord timestamps 2024-12-11 21:18:01 +00:00
190km
a51ada4a1e feat(i18n): add italian language support 2024-12-11 20:39:59 +01:00
190km
50b1de9594 feat: added appname as filename when export 2024-12-11 20:26:19 +01:00
190km
cb90281583 feat: added appname as filename when export 2024-12-11 20:25:49 +01:00
usopp
20b253e708 removed useless state 2024-12-11 18:03:28 +01:00
190km
9a51e0a00d show a message about no matches found 2024-12-11 17:58:35 +01:00
190km
49b812e462 Merge pull request #1 from drudge/log-test
feat(logs): improvements to searching
2024-12-11 16:49:05 +01:00
Nicholas Penree
7233667d49 feat(logs): improvements to searching 2024-12-11 10:35:12 -05:00
Nicholas Penree
95cd410825 feat(logs): improvements to searching 2024-12-11 10:29:09 -05:00
Andreassenemyr
5b8ebdaaa4 feat: improve github app validation 2024-12-11 13:31:07 +01:00
djknaeckebrot
343d5ae6a2 feat: add service status 2024-12-11 08:27:18 +01:00
djknaeckebrot
e16ce0c817 refactor: only show Monitoring, Traefik, Docker and Requests when installation is not a cloud installation 2024-12-11 07:36:23 +01:00
Mauricio Siu
3b2440b1db Merge pull request #835 from Dokploy/560-self-hosted-gitlab
560 self hosted gitlab
2024-12-10 22:07:59 -06:00
Mauricio Siu
2f72ccbea7 Merge pull request #855 from hua1995116/fix/memory-bug
fix: error about memoryReservation not set
2024-12-10 21:55:41 -06:00
Mauricio Siu
be47f6d09a Merge pull request #848 from minagishl/feat/i18n-ja
feat(i18n): add japanese language support
2024-12-10 21:54:06 -06:00
Mauricio Siu
9a65bf8e21 chore: remove unused file 2024-12-10 21:53:32 -06:00
Mauricio Siu
15959fa91f fix: add missing migration 2024-12-10 21:52:09 -06:00
Mauricio Siu
f0c14d144c Merge branch 'canary' into 560-self-hosted-gitlab 2024-12-10 21:50:09 -06:00
Mauricio Siu
725b763aa8 Merge pull request #854 from DJKnaeckebrot/560-self-hosted-gitlab
560 self hosted gitlab additions
2024-12-10 21:48:39 -06:00
Mauricio Siu
c0bfd7dde7 Merge pull request #851 from drudge/n8n-template-update
chore: update n8n template to 1.70.3
2024-12-10 21:45:23 -06:00
Mauricio Siu
31ba5a784d Merge pull request #850 from drudge/logto-template
feat: add Logto template
2024-12-10 21:45:09 -06:00
190km
00f9e262a9 feat(logs): new logs style and system 2024-12-11 00:20:22 +01:00
djknaeckebrot
54b6a850b7 refactor: make command globally available 2024-12-10 14:15:39 +01:00
djknaeckebrot
029cbf4498 refactor: change location of search-command 2024-12-10 14:09:04 +01:00
hua1995116
8a1cba470c fix: error about memoryReservation not set 2024-12-10 20:40:26 +08:00
djknaeckebrot
84bb98c7e6 feat: add search command dialog with projects and services 2024-12-10 13:37:57 +01:00
djknaeckebrot
cbf0f37a49 feat: add search command dialog with projects and services 2024-12-10 13:37:46 +01:00
djknaeckebrot
2c22aa3689 chore: remove debug logs 2024-12-10 10:25:48 +01:00
Mauricio Siu
46289305e8 Merge pull request #853 from Dokploy/canary
v0.14.1
2024-12-10 02:51:45 -06:00
Mauricio Siu
0b3e15aabc Merge pull request #852 from Dokploy/fix/nixpacks-version
fix: pin nixpacks version
2024-12-10 02:50:26 -06:00
Mauricio Siu
69a3583717 chore: bump version 2024-12-10 02:39:31 -06:00
Mauricio Siu
bc55fde6d6 fix: pin nixpacks version 2024-12-10 02:38:50 -06:00
djknaeckebrot
1cb1da8097 fix: replace hardcoded gitlab.com with dynamic urls 2024-12-10 09:19:01 +01:00
djknaeckebrot
0698ac8318 chore: remove debug output 2024-12-10 08:54:22 +01:00
djknaeckebrot
7ced6840fa fix: change hardcoded gitlab.com to gitlabUrl 2024-12-10 08:53:53 +01:00
djknaeckebrot
22e6d07f60 fix: hardcoded gitlab.com to gitlabUrl 2024-12-10 08:37:55 +01:00
Nicholas Penree
6a9690fe3c chore: update n8n template to 1.70.3 2024-12-09 22:57:32 -05:00
Nicholas Penree
1c02478688 feat: add Logto template 2024-12-09 22:28:47 -05:00
minagishl
bbe72ad584 feat(i18n): add japanese language support 2024-12-09 15:15:16 +09:00
Mauricio Siu
83b3176f6f Merge pull request #837 from wish-oss/fix/gpu-setup
fix: update GPU setup command to use sudo and add error handling
2024-12-08 22:09:23 -06:00
Mauricio Siu
7ecd1627c8 Merge pull request #845 from Dokploy/canary
v0.14.0
2024-12-08 20:59:38 -06:00
Mauricio Siu
96fbfa7ef5 Merge pull request #847 from Dokploy/feat/improve-server-setup
Feat/improve server setup
2024-12-08 20:06:02 -06:00
Mauricio Siu
6874ede933 refactor: show validate server enabled 2024-12-08 20:05:16 -06:00
Mauricio Siu
012f8ff2f5 feat: add validate server 2024-12-08 20:01:37 -06:00
Mauricio Siu
9a7ed91a55 feat: add validate server 2024-12-08 19:37:11 -06:00
Mauricio Siu
13e9a50959 Merge pull request #844 from drudge/canary
docs: Update ryot links
2024-12-08 18:26:34 -06:00
Nicholas Penree
d424ed23f5 docs: Update ryot links 2024-12-08 19:17:32 -05:00
Mauricio Siu
bf9abbc37c chore: bump version 2024-12-08 17:49:23 -06:00
Mauricio Siu
b66e8c8855 Merge pull request #843 from Dokploy/feat/enhancement-script
Feat/enhancement script
2024-12-08 17:41:20 -06:00
Mauricio Siu
ce0e9ccddc refactor: add preview deployments for cloud version 2024-12-08 17:30:32 -06:00
Mauricio Siu
e03aef8e37 refactor: improve script to support more OS 2024-12-08 17:09:28 -06:00
Mauricio Siu
d0be2a2090 Merge branch 'canary' into feat/enhancement-script 2024-12-08 15:03:12 -06:00
Mauricio Siu
30eb1719b0 Merge pull request #840 from drudge/browserless-template
feat: add Browserless template
2024-12-08 01:07:53 -06:00
Mauricio Siu
9e7299517f Merge branch 'canary' into browserless-template 2024-12-08 01:07:46 -06:00
Mauricio Siu
cd061916df Merge pull request #839 from drudge/drawio-template
feat: add draw.io template
2024-12-08 01:02:22 -06:00
Mauricio Siu
6f818a833c Merge branch 'canary' into drawio-template 2024-12-08 01:02:13 -06:00
Mauricio Siu
00764ffd43 Update apps/dokploy/templates/drawio/docker-compose.yml 2024-12-08 01:01:46 -06:00
Mauricio Siu
e2a16a5723 Update apps/dokploy/templates/drawio/docker-compose.yml 2024-12-08 01:01:42 -06:00
Mauricio Siu
73b622eb2f Merge pull request #838 from drudge/kimai-template
feat: add Kimai 2 template
2024-12-08 00:58:40 -06:00
Mauricio Siu
cf3cea6146 Merge pull request #836 from drudge/budibase-template
feat: add Budibase template
2024-12-08 00:55:09 -06:00
Mauricio Siu
b8e41e970d refactor: add password key 2024-12-08 00:54:39 -06:00
Mauricio Siu
60b0ae5053 Merge pull request #798 from Dokploy/379-preview-deployment
feat: add preview deployments #379
2024-12-08 00:16:41 -06:00
Mauricio Siu
ff8263d8f6 refactor: update 2024-12-07 22:52:23 -06:00
Mauricio Siu
f65822ca7e refactor: recreate message when is deleted 2024-12-07 22:48:30 -06:00
Mauricio Siu
3feead31d9 Merge branch 'canary' into 379-preview-deployment 2024-12-07 21:57:19 -06:00
Mauricio Siu
b1fd1fb306 refactor: enable preview deployments if autodeploy is disabled 2024-12-07 21:48:51 -06:00
Mauricio Siu
46cd22038b refactor: clean code 2024-12-07 21:47:16 -06:00
Mauricio Siu
5058d9b47d Merge branch 'canary' into 379-preview-deployment 2024-12-07 21:28:34 -06:00
Mauricio Siu
ddf95d87bd refactor: add multiple OS 2024-12-07 21:20:31 -06:00
Nicholas Penree
28a5be984d feat: add Browserless template 2024-12-07 22:00:12 -05:00
Nicholas Penree
1650f5ca79 feat: add draw.io template 2024-12-07 21:26:36 -05:00
Nicholas Penree
e023cad72d feat: add Kimai 2 template 2024-12-07 20:45:20 -05:00
vishalkadam47
49559ebee6 fix: update GPU setup command to use sudo and add error handling 2024-12-08 06:27:34 +05:30
Nicholas Penree
6cf0ecf016 feat: add Budibase template 2024-12-07 19:47:21 -05:00
Nicholas Penree
1562396339 feat: add Budibase template 2024-12-07 19:46:43 -05:00
Mauricio Siu
f94ee8c299 refactor: update 2024-12-07 16:50:25 -06:00
Mauricio Siu
f47335efe5 refactor: wip 2024-12-07 16:47:39 -06:00
Mauricio Siu
c8b5889414 Merge pull request #833 from drudge/canary
docs: Clarify _HOST suffix in contribution guide
2024-12-07 16:11:56 -06:00
Nicholas Penree
124d81bb1c Update CONTRIBUTING.md 2024-12-07 17:01:32 -05:00
Mauricio Siu
5f987d28c7 Merge pull request #831 from Dokploy/738-code-editor-for-file-mounts
refactor: add code editor in volumes edit
2024-12-07 14:10:03 -06:00
Mauricio Siu
32b19a0fb6 refactor: add code editor in volumes edit 2024-12-07 14:09:31 -06:00
Mauricio Siu
5f71a393be Merge pull request #829 from Dokploy/refactor/enhancement-languages
refactor: improve I18N
2024-12-07 14:02:05 -06:00
Mauricio Siu
f4bd729f65 test: add missing fields 2024-12-07 14:01:48 -06:00
Mauricio Siu
3320e21958 Merge pull request #830 from Dokploy/815-edit-volumes-can-not-get-right-mount-path
fix: show mount path when is not compose
2024-12-07 13:57:21 -06:00
Mauricio Siu
2960d81829 fix: show mount path when is not compose 2024-12-07 13:55:00 -06:00
Mauricio Siu
1c1e52f777 fix: lint 2024-12-07 13:50:38 -06:00
Mauricio Siu
64e6919211 refactor: improve I18N 2024-12-07 13:45:50 -06:00
Mauricio Siu
a53daed434 Merge pull request #814 from yerkow/i18n-kazakh
feat(i18n): add kazakh language support
2024-12-07 13:29:09 -06:00
Mauricio Siu
791c8afab7 Merge pull request #828 from Dokploy/feat/custom-heroku-version
Feat/custom heroku version
2024-12-07 13:28:58 -06:00
Mauricio Siu
4c45be1447 feat: add heroku version field 2024-12-07 13:27:54 -06:00
idicesystem
1056810170 Add support for configurable Heroku stack version
Added a new environment variable HEROKU_STACK_VERSION that can be used to specify the desired Heroku stack version.
Updated the buildHeroku and getHerokuCommand functions to use the specified stack version, or default to 24 if the environment variable is not set.
Provided information about the available Heroku stack versions and their support details, as per the documentation from the Heroku DevCenter.
https://devcenter.heroku.com/articles/stack#stack-support-details
2024-12-07 13:26:07 -06:00
Mauricio Siu
b3ca81d2e8 Merge branch 'canary' into i18n-kazakh 2024-12-07 13:13:30 -06:00
Mauricio Siu
9d170e5f46 Merge pull request #820 from 190km/prevent-when-closing-terminal
feat: add prevent dialog when leaving a terminal
2024-12-07 13:08:56 -06:00
Mauricio Siu
3d0b6eb368 Merge pull request #823 from mafrasil/add-trigger-dev-template
feat: add trigger.dev template
2024-12-07 13:06:45 -06:00
Mauricio Siu
5db407b674 Update apps/dokploy/templates/triggerdotdev/index.ts 2024-12-07 13:05:00 -06:00
Mauricio Siu
556a847054 Apply suggestions from code review 2024-12-07 13:04:15 -06:00
mafrasil
4c34643287 tweak env 2024-12-06 20:38:02 +04:00
mafrasil
5e590c1ce8 add extra env 2024-12-06 20:35:34 +04:00
mafrasil
2f15f34a19 rename as triggerdotdev 2024-12-06 18:22:04 +04:00
mafrasil
7f53e9cf07 add trigger template 2024-12-06 17:48:21 +04:00
Mauricio Siu
48e3d48ab4 Merge pull request #817 from pedroramon/i18n-ptbr
feat(i18n): add portuguese language support
2024-12-05 21:36:54 -06:00
190km
b9faf4bd1a feat: add prevent dialog when leaving a terminal 2024-12-05 23:39:03 +01:00
yerkow
27f43e774a fix: kz label 2024-12-05 10:39:10 +05:00
yerkow
c9d3616088 feat(i18n): add kazakh language support 2024-12-05 10:17:54 +05:00
Mauricio Siu
7fe8cd03bf Merge pull request #808 from DanielGietmann/umami-patch-1
Feat<templates>:Updated Umami to v2.14.0
2024-12-04 21:48:33 -06:00
Mauricio Siu
38e5d244fe Merge pull request #810 from ShahriarKh/canary
fix: small typo
2024-12-04 21:48:26 -06:00
Shahriar
14573f90f7 fix: small typo 2024-12-04 16:21:07 +03:30
Pedro Ramon
cbbbe44802 feat(i18n): add portuguese language support 2024-12-04 07:39:36 -03:00
Daniel Gietmann
00c7ae3f40 Updated Umami to v2.14.0 2024-12-03 20:48:46 +01:00
Mauricio Siu
f10eae40c7 Merge pull request #793 from 190km/discord-notifications
style: improved discord webhooks notifications style
2024-12-02 22:01:59 -06:00
Mauricio Siu
df63182f39 Merge pull request #801 from 190km/start-stop-compose-button
fix/feat: fixed stop compose & added start compose
2024-12-02 21:50:51 -06:00
Mauricio Siu
626bf7c41b Merge pull request #802 from 190km/autodeploy-switch
style: added autodeploy switch
2024-12-02 21:37:38 -06:00
Mauricio Siu
75abc4758a Merge pull request #803 from Dokploy/690-dokploy-projects-seem-to-be-linked-together-in-special-cases
fix: allow multiple repositories from same name github #690
2024-12-02 21:36:19 -06:00
Mauricio Siu
c4c4b459cc fix: allow multiple repositories from same name github #690 2024-12-02 21:26:52 -06:00
190km
d8787ec11d style: added autodeploy switch 2024-12-03 03:55:56 +01:00
usopp
40c97b8e9c Update github.ts 2024-12-03 02:42:19 +01:00
190km
fd0a472468 feat/fix: fixed stop button & added start button 2024-12-03 02:20:20 +01:00
Mauricio Siu
db27ec0372 Merge pull request #796 from 190km/request-addr-host
style: add RequestAddr in the requests table
2024-12-01 22:32:39 -06:00
Mauricio Siu
841b264257 feat: add preview deployments #379 2024-12-01 22:29:40 -06:00
usopp
5f6516ab7d Update columns.tsx 2024-12-01 19:42:56 +01:00
190km
2daa159e29 style: add RequestAddr in the requests table 2024-12-01 19:29:16 +01:00
Mauricio Siu
262a2394a9 Merge pull request #794 from myodan/feat/i18n-ko
feat(i18n): add korean language support
2024-12-01 11:25:20 -06:00
Mauricio Siu
e7383e1323 Merge pull request #786 from kerimovok/patch-1
feat<templates>: Updated PocketBase version to 0.23.3
2024-12-01 11:24:50 -06:00
190km
9a8a40b0f8 style: removed useless codeblock fields 2024-12-01 16:37:45 +01:00
Jongho Hong
bcf1ba242e feat(i18n): add korean language support 2024-12-01 16:48:16 +09:00
190km
a235815a13 style: improved discord webhooks notifications 2024-12-01 04:09:19 +01:00
Orkhan Karimov
57594ecb0c feat<templates>: Updated PocketBase version to 0.23.3 2024-11-30 18:01:13 +04:00
225 changed files with 30382 additions and 1622 deletions

View File

@@ -99,14 +99,14 @@ workflows:
only:
- main
- canary
- fix/build-i18n
- fix/nixpacks-version
- build-arm64:
filters:
branches:
only:
- main
- canary
- fix/build-i18n
- fix/nixpacks-version
- combine-manifests:
requires:
- build-amd64
@@ -116,4 +116,4 @@ workflows:
only:
- main
- canary
- fix/build-i18n
- fix/nixpacks-version

1
.gitignore vendored
View File

@@ -34,7 +34,6 @@ yarn-debug.log*
yarn-error.log*
# Editor
.vscode
.idea
# Misc

View File

@@ -241,7 +241,7 @@ export function generate(schema: Schema): Template {
- Use the same name of the folder as the id of the template.
- The logo should be in the public folder.
- If you want to show a domain in the UI, please add the prefix \_HOST at the end of the variable name.
- If you want to show a domain in the UI, please add the `_HOST` suffix at the end of the variable name.
- Test first on a vps or a server to make sure the template works.
## Docs & Website

View File

@@ -35,7 +35,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
COPY --from=build /prod/dokploy/.next ./.next
COPY --from=build /prod/dokploy/dist ./dist
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
COPY --from=build /prod/dokploy/public ./public
COPY --from=build /prod/dokploy/package.json ./package.json
COPY --from=build /prod/dokploy/drizzle ./drizzle
@@ -49,6 +48,8 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.29.1
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \

View File

@@ -44,7 +44,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
COPY --from=build /prod/dokploy/.next ./.next
COPY --from=build /prod/dokploy/dist ./dist
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
COPY --from=build /prod/dokploy/public ./public
COPY --from=build /prod/dokploy/package.json ./package.json
COPY --from=build /prod/dokploy/drizzle ./drizzle

View File

@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -19,6 +19,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
applicationType: z.literal("compose"),
serverId: z.string().min(1),
}),
z.object({
applicationId: z.string(),
previewDeploymentId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),
]);
export type DeployJob = z.infer<typeof deployJobSchema>;

View File

@@ -1,10 +1,12 @@
import {
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildRemoteApplication,
rebuildRemoteCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema";
@@ -47,6 +49,20 @@ export const deploy = async (job: DeployJob) => {
});
}
}
} else if (job.applicationType === "application-preview") {
await updatePreviewDeployment(job.previewDeploymentId, {
previewStatus: "running",
});
if (job.server) {
if (job.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
previewDeploymentId: job.previewDeploymentId,
});
}
}
}
} catch (error) {
if (job.applicationType === "application") {
@@ -55,6 +71,10 @@ export const deploy = async (job: DeployJob) => {
await updateCompose(job.composeId, {
composeStatus: "error",
});
} else if (job.applicationType === "application-preview") {
await updatePreviewDeployment(job.previewDeploymentId, {
previewStatus: "error",
});
}
}

View File

@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -17,6 +17,7 @@ describe("createDomainLabels", () => {
domainId: "",
path: "/",
createdAt: "",
previewDeploymentId: "",
};
it("should create basic labels for web entrypoint", async () => {

View File

@@ -26,6 +26,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
applicationStatus: "done",
appName: "",
autoDeploy: true,
@@ -33,6 +34,15 @@ const baseApp: ApplicationNested = {
registryUrl: "",
branch: null,
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewWildcard: "",
project: {
env: "",
adminId: "",

View File

@@ -6,6 +6,7 @@ import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
applicationStatus: "done",
appName: "",
autoDeploy: true,
@@ -14,6 +15,15 @@ const baseApp: ApplicationNested = {
dockerBuildStage: "",
registryUrl: "",
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewWildcard: "",
project: {
env: "",
adminId: "",
@@ -95,6 +105,7 @@ const baseDomain: Domain = {
composeId: "",
domainType: "application",
uniqueConfigKey: 1,
previewDeploymentId: "",
};
const baseRedirect: Redirect = {

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -18,7 +19,6 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -150,7 +150,7 @@ export const AddVolumes = ({
<DialogTrigger className="" asChild>
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Volumes / Mounts</DialogTitle>
</DialogHeader>
@@ -303,9 +303,12 @@ export const AddVolumes = ({
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
<Textarea
placeholder="Any content"
className="h-64"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -1,8 +1,8 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -19,7 +19,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
@@ -119,7 +118,7 @@ export const UpdateVolume = ({
} else if (typeForm === "file") {
form.reset({
content: data.content || "",
mountPath: "/",
mountPath: serviceType === "compose" ? "/" : data.mountPath,
filePath: data.filePath || "",
type: "file",
});
@@ -182,7 +181,7 @@ export const UpdateVolume = ({
<Pencil className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the mount</DialogDescription>
@@ -247,9 +246,12 @@ export const UpdateVolume = ({
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
<Textarea
placeholder="Any content"
className="h-64"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -41,6 +41,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal("heroku_buildpacks"),
herokuVersion: z.string().nullable().default(""),
}),
z.object({
buildType: z.literal("paketo_buildpacks"),
@@ -90,6 +91,13 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
dockerBuildStage: data.dockerBuildStage || "",
}),
});
} else if (data.buildType === "heroku_buildpacks") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
herokuVersion: data.herokuVersion || "",
}),
});
} else {
form.reset({
buildType: data.buildType,
@@ -110,6 +118,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === "dockerfile" ? data.dockerContextPath : null,
dockerBuildStage:
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
herokuVersion:
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
})
.then(async () => {
toast.success("Build type saved");
@@ -200,6 +210,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
);
}}
/>
{buildType === "heroku_buildpacks" && (
<FormField
control={form.control}
name="herokuVersion"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Heroku Version (Optional)</FormLabel>
<FormControl>
<Input
placeholder={"Heroku Version (Default: 24)"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{buildType === "dockerfile" && (
<>
<FormField

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -102,9 +103,26 @@ export const DeleteApplication = ({ applicationId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -6,6 +6,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line";
import { LogLine, parseLogs } from "../../docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
interface Props {
logPath: string | null;
@@ -15,9 +19,26 @@ interface Props {
}
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const [data, setData] = useState("");
const endOfLogsRef = useRef<HTMLDivElement>(null);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
@@ -48,13 +69,20 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
};
}, [logPath, open]);
const scrollToBottom = () => {
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
const logs = parseLogs(data);
setFilteredLogs(logs);
}, [data]);
useEffect(() => {
scrollToBottom();
}, [data]);
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
return (
<Dialog
@@ -76,17 +104,27 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription>
See all the details of this deployment
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge>
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
<code>
<pre className="whitespace-pre-wrap break-words">
{data || "Loading..."}
</pre>
<div ref={endOfLogsRef} />
</code>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar"
> {
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine
key={index}
log={log}
noTimestamp
/>
)) :
(
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
refetchInterval: 1000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);

View File

@@ -61,7 +61,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-5 "
>
<Card className="bg-background">
<Card className="bg-background p-6">
<Secrets
name="env"
title="Environment Settings"

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
@@ -18,6 +19,7 @@ interface Props {
}
export const DeployApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -51,6 +53,9 @@ export const DeployApplication = ({ applicationId }: Props) => {
.then(async () => {
toast.success("Application deployed succesfully");
await refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {

View File

@@ -2,9 +2,9 @@ import { ShowBuildChooseForm } from "@/components/dashboard/application/build/sh
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Toggle } from "@/components/ui/toggle";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { CheckCircle2, Terminal } from "lucide-react";
import { Terminal } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -39,27 +39,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
appName={data?.appName || ""}
/>
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
>
Autodeploy
{data?.autoDeploy && <CheckCircle2 className="size-4" />}
</Toggle>
<RedbuildApplication applicationId={applicationId} />
{data?.applicationStatus === "idle" ? (
<StartApplication applicationId={applicationId} />
@@ -75,6 +54,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
/>
</div>
</CardContent>
</Card>
<ShowProviderForm applicationId={applicationId} />

View File

@@ -90,7 +90,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</Select>
<DockerLogs
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
/>
</CardContent>

View File

@@ -0,0 +1,304 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import type z from "zod";
type Domain = z.infer<typeof domain>;
interface Props {
previewDeploymentId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddPreviewDomain = ({
previewDeploymentId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { data: previewDeployment } = api.previewDeployment.one.useQuery(
{
previewDeploymentId,
},
{
enabled: !!previewDeploymentId,
},
);
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const form = useForm<Domain>({
resolver: zodResolver(domain),
});
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
});
}
if (!domainId) {
form.reset({});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
previewDeploymentId,
...data,
})
.then(async () => {
toast.success(dictionary.success);
await utils.previewDeployment.all.invalidate({
applicationId: previewDeployment?.applicationId,
});
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error(dictionary.error);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
appName: previewDeployment?.appName || "",
serverId:
previewDeployment?.application
?.serverId || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.getValues().https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,87 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
import { useState } from "react";
import { ShowDeployment } from "../deployments/show-deployment";
interface Props {
deployments: RouterOutputs["deployment"]["all"];
serverId?: string;
}
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">View Builds</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Preview Builds</DialogTitle>
<DialogDescription>
See all the preview builds for this application on this Pull Request
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</Dialog>
);
};

View File

@@ -0,0 +1,212 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Pencil, RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowPreviewBuilds } from "./show-preview-builds";
interface Props {
applicationId: string;
}
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
// const [url, setUrl] = React.useState("");
// useEffect(() => {
// setUrl(document.location.origin);
// }, []);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Preview Deployments</CardTitle>
<CardDescription>See all the preview deployments</CardDescription>
</div>
{data?.isPreviewDeploymentsActive && (
<ShowPreviewSettings applicationId={applicationId} />
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.isPreviewDeploymentsActive ? (
<>
<div className="flex flex-col gap-2 text-sm">
<span>
Preview deployments are a way to test your application before it
is deployed to production. It will create a new deployment for
each pull request you create.
</span>
</div>
{data?.previewDeployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No preview deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{previewDeployments?.map((previewDeployment) => {
const { deployments, domain } = previewDeployment;
return (
<div
key={previewDeployment?.previewDeploymentId}
className="flex flex-col justify-between rounded-lg border p-4 gap-2"
>
<div className="flex justify-between gap-2 max-sm:flex-wrap">
<div className="flex flex-col gap-2">
{deployments?.length === 0 ? (
<div>
<span className="text-sm text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
)}
{previewDeployment?.pullRequestURL && (
<div className="flex items-center gap-2">
<GithubIcon />
<Link
target="_blank"
href={previewDeployment?.pullRequestURL}
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
Pull Request URL
</Link>
</div>
)}
</div>
<div className="flex flex-col ">
<span>Domain </span>
<div className="flex flex-row items-center gap-4">
<Link
target="_blank"
href={`http://${domain?.host}`}
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
}
domainId={domain?.domainId}
>
<Button variant="outline" size="sm">
<Pencil className="size-4 text-muted-foreground" />
</Button>
</AddPreviewDomain>
</div>
</div>
</div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
{previewDeployment?.createdAt && (
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip
date={previewDeployment?.createdAt}
/>
</div>
)}
<ShowPreviewBuilds
deployments={previewDeployment?.deployments || []}
serverId={data?.serverId || ""}
/>
<ShowModalLogs
appName={previewDeployment.appName}
serverId={data?.serverId || ""}
>
<Button variant="outline">View Logs</Button>
</ShowModalLogs>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() => {
deletePreviewDeployment({
previewDeploymentId:
previewDeployment.previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
>
<Button variant="destructive" isLoading={isLoading}>
Delete Preview
</Button>
</DialogAction>
</div>
</div>
</div>
);
})}
</div>
)}
</>
) : (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
Preview deployments are disabled for this application, please
enable it
</span>
<ShowPreviewSettings applicationId={applicationId} />
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,351 @@
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Secrets } from "@/components/ui/secrets";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const schema = z.object({
env: z.string(),
buildArgs: z.string(),
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
previewHttps: z.boolean(),
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none"]),
});
type Schema = z.infer<typeof schema>;
interface Props {
applicationId: string;
}
export const ShowPreviewSettings = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewHttps: false,
previewPath: "/",
previewCertificateType: "none",
},
resolver: zodResolver(schema),
});
const previewHttps = form.watch("previewHttps");
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
}, [data?.isPreviewDeploymentsActive]);
useEffect(() => {
if (data) {
form.reset({
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLimit: data.previewLimit || 3,
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
});
}
}, [data]);
const onSubmit = async (formData: Schema) => {
updateApplication({
previewEnv: formData.env,
previewBuildArgs: formData.buildArgs,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
applicationId,
previewLimit: formData.previewLimit,
previewHttps: formData.previewHttps,
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
})
.then(() => {
toast.success("Preview Deployments settings updated");
})
.catch((error) => {
toast.error(error.message);
});
};
return (
<div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">View Settings</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
<DialogHeader>
<DialogTitle>Preview Deployment Settings</DialogTitle>
<DialogDescription>
Adjust the settings for preview deployments of this application,
including environment variables, build options, and deployment
rules.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-application"
className="grid w-full gap-4"
>
<div className="grid gap-4 lg:grid-cols-2">
<FormField
control={form.control}
name="wildcardDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.traefik.me" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewPath"
render={({ field }) => (
<FormItem>
<FormLabel>Preview Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<NumberInput placeholder="3000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewLimit"
render={({ field }) => (
<FormItem>
<FormLabel>Preview Limit</FormLabel>
{/* <FormDescription>
Set the limit of preview deployments that can be
created for this app.
</FormDescription> */}
<FormControl>
<NumberInput placeholder="3000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewHttps"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{previewHttps && (
<FormField
control={form.control}
name="previewCertificateType"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable preview deployments
</FormLabel>
<FormDescription>
Enable or disable preview deployments for this
application.
</FormDescription>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => {
updateApplication({
isPreviewDeploymentsActive: checked,
applicationId,
})
.then(() => {
refetch();
toast.success("Preview deployments enabled");
})
.catch((error) => {
toast.error(error.message);
});
}}
/>
</div>
</div>
<FormField
control={form.control}
name="env"
render={({ field }) => (
<FormItem>
<FormControl>
<Secrets
name="env"
title="Environment Settings"
description="You can add environment variables to your resource."
placeholder={[
"NODE_ENV=production",
"PORT=3000",
].join("\n")}
/>
{/* <CodeEditor
lineWrapping
language="properties"
wrapperClassName="h-[25rem] font-mono"
placeholder={`NODE_ENV=production
PORT=3000
`}
{...field}
/> */}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
description={
<span>
Available only at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/guide/build-args/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
</form>
</Form>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-application"
type="submit"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* */}
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,6 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Copy } from "lucide-react";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
@@ -100,10 +102,27 @@ export const DeleteCompose = ({ composeId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
</FormLabel>{" "}
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"

View File

@@ -6,6 +6,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line";
import { LogLine, parseLogs } from "../../docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
interface Props {
logPath: string | null;
@@ -20,9 +25,26 @@ export const ShowDeploymentCompose = ({
serverId,
}: Props) => {
const [data, setData] = useState("");
const endOfLogsRef = useRef<HTMLDivElement>(null);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
@@ -54,13 +76,19 @@ export const ShowDeploymentCompose = ({
};
}, [logPath, open]);
const scrollToBottom = () => {
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
const logs = parseLogs(data);
setFilteredLogs(logs);
}, [data]);
useEffect(() => {
scrollToBottom();
}, [data]);
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
return (
<Dialog
@@ -78,21 +106,35 @@ export const ShowDeploymentCompose = ({
}
}}
>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription>
See all the details of this deployment
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge>
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
<code>
<pre className="whitespace-pre-wrap break-words">
{data || "Loading..."}
</pre>
<div ref={endOfLogsRef} />
</code>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine
key={index}
log={log}
noTimestamp
/>
)) :
(
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)
}
</div>
</DialogContent>
</Dialog>

View File

@@ -8,12 +8,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Toggle } from "@/components/ui/toggle";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import { StartCompose } from "../start-compose";
import { DeployCompose } from "./deploy-compose";
import { RedbuildCompose } from "./rebuild-compose";
import { StopCompose } from "./stop-compose";
@@ -50,28 +51,11 @@ export const ComposeActions = ({ composeId }: Props) => {
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DeployCompose composeId={composeId} />
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
>
Autodeploy {data?.autoDeploy && <CheckCircle2 className="size-4" />}
</Toggle>
<RedbuildCompose composeId={composeId} />
{data?.composeType === "docker-compose" && (
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<StartCompose composeId={composeId} />
) : (
<StopCompose composeId={composeId} />
)}
@@ -84,6 +68,27 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
/>
</div>
{domains.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
@@ -18,6 +19,7 @@ interface Props {
}
export const DeployCompose = ({ composeId }: Props) => {
const router = useRouter();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -48,9 +50,15 @@ export const DeployCompose = ({ composeId }: Props) => {
await refetch();
await deploy({
composeId,
}).catch(() => {
toast.error("Error to deploy Compose");
});
})
.then(async () => {
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`
);
})
.catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
}}

View File

@@ -96,7 +96,6 @@ export const ShowDockerLogsCompose = ({
</Select>
<DockerLogs
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
/>
</CardContent>

View File

@@ -0,0 +1,65 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StartCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.start.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Start
<CheckCircle2 className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to start the compose?
</AlertDialogTitle>
<AlertDialogDescription>
This will start the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose started succesfully");
})
.catch(() => {
toast.error("Error to start the Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,65 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure to stop the compose?
</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose stopped succesfully");
})
.catch(() => {
toast.error("Error to stop the Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import {
Dialog,
DialogContent,
@@ -34,7 +35,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
View Config
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className={"w-full md:w-[70vw] max-w-max"}>
<DialogContent className={"w-full md:w-[70vw] min-w-[70vw]"}>
<DialogHeader>
<DialogTitle>Container Config</DialogTitle>
<DialogDescription>
@@ -44,7 +45,13 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>

View File

@@ -1,115 +1,309 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Terminal } from "@xterm/xterm";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { Download as DownloadIcon, Loader2 } from "lucide-react";
import React, { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { TerminalLine } from "./terminal-line";
import { type LogLine, getLogType, parseLogs } from "./utils";
interface Props {
id: string;
containerId: string;
serverId?: string | null;
containerId: string;
serverId?: string | null;
}
export const DockerLogsId: React.FC<Props> = ({
id,
containerId,
serverId,
}) => {
const [term, setTerm] = React.useState<Terminal>();
const [lines, setLines] = React.useState<number>(40);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
type TypeFilter = "all" | "error" | "warning" | "success" | "info" | "debug";
useEffect(() => {
// if (containerId === "select-a-container") {
// return;
// }
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
}
export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId: serverId ?? undefined,
},
{
enabled: !!containerId,
}
);
if (wsRef.current) {
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close();
}
wsRef.current = null;
}
const termi = new Terminal({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.25,
fontWeight: 400,
fontSize: 14,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
const [rawLogs, setRawLogs] = React.useState("");
const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = React.useState(true);
const [lines, setLines] = React.useState<number>(100);
const [search, setSearch] = React.useState<string>("");
convertEol: true,
theme: {
cursor: "transparent",
background: "rgba(0, 0, 0, 0)",
},
});
const [since, setSince] = React.useState<TimeFilter>("all");
const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all");
const scrollRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = React.useState(false);
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const fitAddon = new FitAddon();
termi.loadAddon(fitAddon);
// @ts-ignore
termi.open(container);
fitAddon.fit();
termi.focus();
setTerm(termi);
const handleScroll = () => {
if (!scrollRef.current) return;
ws.onerror = (error) => {
console.error("WebSocket error: ", error);
};
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
ws.onmessage = (e) => {
termi.write(e.data);
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value || "");
};
ws.onclose = (e) => {
console.log(e.reason);
const handleLines = (e: React.ChangeEvent<HTMLInputElement>) => {
setRawLogs("");
setFilteredLogs([]);
setLines(Number(e.target.value) || 1);
};
termi.write(`Connection closed!\nReason: ${e.reason}\n`);
wsRef.current = null;
};
return () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
ws.close();
wsRef.current = null;
}
};
}, [lines, containerId]);
const handleSince = (value: TimeFilter) => {
setRawLogs("");
setFilteredLogs([]);
setSince(value);
};
useEffect(() => {
term?.clear();
}, [lines, term]);
const handleTypeFilter = (value: TypeFilter) => {
setTypeFilter(value);
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>
<span>Number of lines to show</span>
</Label>
<Input
type="text"
placeholder="Number of lines to show (Defaults to 35)"
value={lines}
onChange={(e) => {
setLines(Number(e.target.value) || 1);
}}
/>
</div>
useEffect(() => {
if (!containerId) return;
let isCurrentConnection = true;
let noDataTimeout: NodeJS.Timeout;
setIsLoading(true);
setRawLogs("");
setFilteredLogs([]);
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div id={id} />
</div>
</div>
);
};
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new globalThis.URLSearchParams({
containerId,
tail: lines.toString(),
since,
search,
});
if (serverId) {
params.append("serverId", serverId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
console.log("Connecting to WebSocket:", wsUrl);
const ws = new WebSocket(wsUrl);
const resetNoDataTimeout = () => {
if (noDataTimeout) clearTimeout(noDataTimeout);
noDataTimeout = setTimeout(() => {
if (isCurrentConnection) {
setIsLoading(false);
}
}, 2000); // Wait 2 seconds for data before showing "No logs found"
};
ws.onopen = () => {
if (!isCurrentConnection) {
ws.close();
return;
}
console.log("WebSocket connected");
resetNoDataTimeout();
};
ws.onmessage = (e) => {
if (!isCurrentConnection) return;
setRawLogs((prev) => prev + e.data);
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};
ws.onerror = (error) => {
if (!isCurrentConnection) return;
console.error("WebSocket error:", error);
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};
ws.onclose = (e) => {
if (!isCurrentConnection) return;
console.log("WebSocket closed:", e.reason);
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};
return () => {
isCurrentConnection = false;
if (noDataTimeout) clearTimeout(noDataTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, [containerId, serverId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs
.map(
({ timestamp, message }: { timestamp: Date | null; message: string }) =>
`${timestamp?.toISOString() || "No timestamp"} ${message}`
)
.join("\n");
const blob = new Blob([logContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const appName = data.Name.replace("/", "") || "app";
const isoDate = new Date().toISOString();
a.href = url;
a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate
.slice(11, 19)
.replace(/:/g, "")}.log.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleFilter = (logs: LogLine[]) => {
return logs.filter((log) => {
const logType = getLogType(log.message).type;
const matchesType = typeFilter === "all" || logType === typeFilter;
return matchesType;
});
};
useEffect(() => {
setRawLogs("");
setFilteredLogs([]);
}, [containerId]);
useEffect(() => {
const logs = parseLogs(rawLogs);
const filtered = handleFilter(logs);
setFilteredLogs(filtered);
}, [rawLogs, search, lines, since, typeFilter]);
useEffect(() => {
scrollToBottom();
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg overflow-hidden">
<div className="space-y-4">
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
<div className="flex flex-wrap gap-4">
<Input
type="text"
placeholder="Number of lines to show"
value={lines}
onChange={handleLines}
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
/>
<Select value={since} onValueChange={handleSince}>
<SelectTrigger className="sm:w-[180px] w-full h-9">
<SelectValue placeholder="Time filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last hour</SelectItem>
<SelectItem value="6h">Last 6 hours</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="168h">Last 7 days</SelectItem>
<SelectItem value="720h">Last 30 days</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={handleTypeFilter}>
<SelectTrigger className="sm:w-[180px] w-full h-9">
<SelectValue placeholder="Type filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<Badge variant="blank">All</Badge>
</SelectItem>
<SelectItem value="error">
<Badge variant="red">Error</Badge>
</SelectItem>
<SelectItem value="warning">
<Badge variant="orange">Warning</Badge>
</SelectItem>
<SelectItem value="debug">
<Badge variant="yellow">Debug</Badge>
</SelectItem>
<SelectItem value="success">
<Badge variant="green">Success</Badge>
</SelectItem>
<SelectItem value="info">
<Badge variant="blue">Info</Badge>
</SelectItem>
</SelectContent>
</Select>
<Input
type="search"
placeholder="Search logs..."
value={search}
onChange={handleSearch}
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
<TerminalLine
key={index}
log={filteredLog}
searchTerm={search}
/>
))
) : isLoading ? (
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
No logs found
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -46,11 +46,7 @@ export const ShowDockerModalLogs = ({
<DialogDescription>View the logs for {containerId}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
id="terminal"
containerId={containerId || ""}
serverId={serverId}
/>
<DockerLogsId containerId={containerId || ""} serverId={serverId} />
</div>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,111 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { escapeRegExp } from "lodash";
import React from "react";
import { type LogLine, getLogType } from "./utils";
interface LogLineProps {
log: LogLine;
noTimestamp?: boolean;
searchTerm?: string;
}
export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const { timestamp, message, rawTimestamp } = log;
const { type, variant, color } = getLogType(message);
const formattedTime = timestamp
? timestamp.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
year: "2-digit",
second: "2-digit",
})
: "--- No time found ---";
const highlightMessage = (text: string, term: string) => {
if (!term) return text;
const parts = text.split(new RegExp(`(${escapeRegExp(term)})`, "gi"));
return parts.map((part, index) =>
part.toLowerCase() === term.toLowerCase() ? (
<span key={index} className="bg-yellow-200 dark:bg-yellow-900">
{part}
</span>
) : (
part
),
);
};
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div className={cn("w-2 h-full flex-shrink-0 rounded-[3px]", color)} />
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>{square}</TooltipTrigger>
<TooltipPortal>
<TooltipContent
sideOffset={5}
className="bg-popover border-border z-[99999]"
>
<p className="text text-xs text-muted-foreground break-all max-w-md">
<pre>{timestamp}</pre>
</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
) : (
square
);
};
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
? "bg-yellow-500/10 hover:bg-yellow-500/15"
: type === "debug"
? "bg-orange-500/10 hover:bg-orange-500/15"
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/50",
)}
>
{" "}
<div className="flex items-start gap-x-2">
{/* Icon to expand the log item maybe implement a colapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 flex-shrink-0">
{formattedTime}
</span>
)}
<Badge
variant={variant}
className="w-14 justify-center text-[10px] px-1 py-0"
>
{type}
</Badge>
</div>
<span className="dark:text-gray-200 font-mono text-foreground whitespace-pre-wrap break-all">
{searchTerm ? highlightMessage(message, searchTerm) : message}
</span>
</div>
);
}

View File

@@ -0,0 +1,148 @@
export type LogType = "error" | "warning" | "success" | "info" | "debug";
export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange";
export interface LogLine {
rawTimestamp: string | null;
timestamp: Date | null;
message: string;
}
interface LogStyle {
type: LogType;
variant: LogVariant;
color: string;
}
const LOG_STYLES: Record<LogType, LogStyle> = {
error: {
type: "error",
variant: "red",
color: "bg-red-500/40",
},
warning: {
type: "warning",
variant: "orange",
color: "bg-orange-500/40",
},
debug: {
type: "debug",
variant: "yellow",
color: "bg-yellow-500/40",
},
success: {
type: "success",
variant: "green",
color: "bg-green-500/40",
},
info: {
type: "info",
variant: "blue",
color: "bg-blue-600/40",
},
} as const;
export function parseLogs(logString: string): LogLine[] {
// Regex to match the log line format
// Exemple of return :
// 1 2024-12-10T10:00:00.000Z The server is running on port 8080
// Should return :
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
// message: "The server is running on port 8080" }
const logRegex =
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
return logString
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "")
.map((line) => {
const match = line.match(logRegex);
if (!match) return null;
const [, , timestamp, message] = match;
if (!message?.trim()) return null;
// Delete other timestamps and keep only the one from --timestamps
const cleanedMessage = message
?.replace(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC/g,
"",
)
.trim();
return {
rawTimestamp: timestamp ?? null,
timestamp: timestamp ? new Date(timestamp.replace(" UTC", "Z")) : null,
message: cleanedMessage,
};
})
.filter((log) => log !== null);
}
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
const lowerMessage = message.toLowerCase();
if (
/(?:^|\s)(?:info|inf|information):?\s/i.test(lowerMessage) ||
/\[(?:info|information)\]/i.test(lowerMessage) ||
/\b(?:status|state|current|progress)\b:?\s/i.test(lowerMessage) ||
/\b(?:processing|executing|performing)\b/i.test(lowerMessage)
) {
return LOG_STYLES.info;
}
if (
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
) {
return LOG_STYLES.error;
}
if (
/(?:^|\s)(?:warning|warn):?\s/i.test(lowerMessage) ||
/\[(?:warn(?:ing)?|attention)\]/i.test(lowerMessage) ||
/(?:deprecated|obsolete)\s+(?:since|in|as\s+of)/i.test(lowerMessage) ||
/\b(?:caution|attention|notice):\s/i.test(lowerMessage) ||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
) {
return LOG_STYLES.warning;
}
if (
/(?:successfully|complete[d]?)\s+(?:initialized|started|completed|created|done|deployed)/i.test(
lowerMessage,
) ||
/\[(?:success|ok|done)\]/i.test(lowerMessage) ||
/(?:listening|running)\s+(?:on|at)\s+(?:port\s+)?\d+/i.test(lowerMessage) ||
/(?:connected|established|ready)\s+(?:to|for|on)/i.test(lowerMessage) ||
/\b(?:loaded|mounted|initialized)\s+successfully\b/i.test(lowerMessage) ||
/✓|√|✅|\[ok\]|done!/i.test(lowerMessage) ||
/\b(?:success(?:ful)?|completed|ready)\b/i.test(lowerMessage) ||
/\b(?:started|starting|active)\b/i.test(lowerMessage)
) {
return LOG_STYLES.success;
}
if (
/(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) ||
/\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(lowerMessage) ||
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(lowerMessage)
) {
return LOG_STYLES.debug;
}
return LOG_STYLES.info;
};

View File

@@ -1,13 +1,16 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import dynamic from "next/dynamic";
import { useState } from "react";
const Terminal = dynamic(
() => import("./docker-terminal").then((e) => e.DockerTerminal),
@@ -27,8 +30,27 @@ export const DockerTerminalModal = ({
containerId,
serverId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const handleMainDialogOpenChange = (open: boolean) => {
if (!open) {
setConfirmDialogOpen(true);
} else {
setMainDialogOpen(true);
}
};
const handleConfirm = () => {
setConfirmDialogOpen(false);
setMainDialogOpen(false);
};
const handleCancel = () => {
setConfirmDialogOpen(false);
};
return (
<Dialog>
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
@@ -50,6 +72,24 @@ export const DockerTerminalModal = ({
containerId={containerId}
serverId={serverId || ""}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Are you sure you want to close the terminal?
</DialogTitle>
<DialogDescription>
By clicking the confirm button, the terminal will be closed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);

View File

@@ -25,8 +25,6 @@ export const DockerTerminal: React.FC<Props> = ({
}
const term = new Terminal({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
convertEol: true,
theme: {
@@ -45,6 +43,7 @@ export const DockerTerminal: React.FC<Props> = ({
const addonAttach = new AttachAddon(ws);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
@@ -66,7 +65,7 @@ export const DockerTerminal: React.FC<Props> = ({
</TabsList>
</Tabs>
</div>
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} />
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -99,9 +100,26 @@ export const DeleteMariadb = ({ mariadbId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -98,9 +99,26 @@ export const DeleteMongo = ({ mongoId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -97,9 +98,26 @@ export const DeleteMysql = ({ mysqlId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -100,9 +101,26 @@ export const DeletePostgres = ({ postgresId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -92,7 +92,8 @@ export const AddTemplate = ({ projectId }: Props) => {
template.tags.some((tag) => selectedTags.includes(tag));
const matchesQuery =
query === "" ||
template.name.toLowerCase().includes(query.toLowerCase());
template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase());
return matchesTags && matchesQuery;
}) || [];

View File

@@ -1,35 +1,35 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import {
AlertTriangle,
BookIcon,
ExternalLink,
ExternalLinkIcon,
FolderInput,
MoreHorizontalIcon,
TrashIcon,
AlertTriangle,
BookIcon,
ExternalLink,
ExternalLinkIcon,
FolderInput,
MoreHorizontalIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { Fragment } from "react";
@@ -38,253 +38,257 @@ import { ProjectEnviroment } from "./project-enviroment";
import { UpdateProject } from "./update";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { mutateAsync } = api.project.remove.useMutation();
const utils = api.useUtils();
const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
}
);
const { mutateAsync } = api.project.remove.useMutation();
return (
<>
{data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project.
</span>
</div>
)}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
project?.mongo.length === 0 &&
project?.mysql.length === 0 &&
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0 &&
project?.compose.length === 0;
return (
<>
{data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project.
</span>
</div>
)}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
project?.mongo.length === 0 &&
project?.mysql.length === 0 &&
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0 &&
project?.compose.length === 0;
const totalServices =
project?.mariadb.length +
project?.mongo.length +
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length +
project?.compose.length;
const totalServices =
project?.mariadb.length +
project?.mongo.length +
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length +
project?.compose.length;
const flattedDomains = [
...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains),
];
const flattedDomains = [
...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains),
];
const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications,
) =>
item[0] ? (
<DropdownMenuGroup>
<DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel>
{item.map((a) => (
<Fragment
key={"applicationId" in a ? a.applicationId : a.composeId}
>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span>{domain.host}</span>
<ExternalLink className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</Fragment>
))}
</DropdownMenuGroup>
) : null;
const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications
) =>
item[0] ? (
<DropdownMenuGroup>
<DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel>
{item.map((a) => (
<Fragment
key={"applicationId" in a ? a.applicationId : a.composeId}
>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${
domain.host
}${domain.path}`}
>
<span>{domain.host}</span>
<ExternalLink className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</Fragment>
))}
</DropdownMenuGroup>
) : null;
return (
<div key={project.projectId} className="w-full lg:max-w-md">
<Link href={`/dashboard/project/${project.projectId}`}>
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
{flattedDomains.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
>
<ExternalLinkIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2"
onClick={(e) => e.stopPropagation()}
>
{renderDomainsDropdown(project.applications)}
{renderDomainsDropdown(project.compose)}
</DropdownMenuContent>
</DropdownMenu>
) : flattedDomains[0] ? (
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
onClick={(e) => e.stopPropagation()}
>
<Link
href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`}
target="_blank"
>
<ExternalLinkIcon className="size-3.5" />
</Link>
</Button>
) : null}
return (
<div key={project.projectId} className="w-full lg:max-w-md">
<Link href={`/dashboard/project/${project.projectId}`}>
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
{flattedDomains.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
>
<ExternalLinkIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2"
onClick={(e) => e.stopPropagation()}
>
{renderDomainsDropdown(project.applications)}
{renderDomainsDropdown(project.compose)}
</DropdownMenuContent>
</DropdownMenu>
) : flattedDomains[0] ? (
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
onClick={(e) => e.stopPropagation()}
>
<Link
href={`${
flattedDomains[0].https ? "https" : "http"
}://${flattedDomains[0].host}${flattedDomains[0].path}`}
target="_blank"
>
<ExternalLinkIcon className="size-3.5" />
</Link>
</Button>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
{project.name}
</span>
</div>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
{project.name}
</span>
</div>
<span className="text-sm font-medium text-muted-foreground">
{project.description}
</span>
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="px-2"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuLabel className="font-normal">
Actions
</DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}>
<ProjectEnviroment
projectId={project.projectId}
/>
</div>
<div onClick={(e) => e.stopPropagation()}>
<UpdateProject projectId={project.projectId} />
</div>
<span className="text-sm font-medium text-muted-foreground">
{project.description}
</span>
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="px-2"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuLabel className="font-normal">
Actions
</DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}>
<ProjectEnviroment
projectId={project.projectId}
/>
</div>
<div onClick={(e) => e.stopPropagation()}>
<UpdateProject projectId={project.projectId} />
</div>
<div onClick={(e) => e.stopPropagation()}>
{(auth?.rol === "admin" ||
user?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<TrashIcon className="size-4" />
<span>Delete</span>
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to delete this project?
</AlertDialogTitle>
{!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
You have active services, please
delete them first
</span>
</div>
) : (
<AlertDialogDescription>
This action cannot be undone
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!emptyServices}
onClick={async () => {
await mutateAsync({
projectId: project.projectId,
})
.then(() => {
toast.success(
"Project delete succesfully",
);
})
.catch(() => {
toast.error(
"Error to delete this project",
);
})
.finally(() => {
utils.project.all.invalidate();
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
<span>
{totalServices}{" "}
{totalServices === 1 ? "service" : "services"}
</span>
</div>
</CardFooter>
</Card>
</Link>
</div>
);
})}
</div>
</>
);
<div onClick={(e) => e.stopPropagation()}>
{(auth?.rol === "admin" ||
user?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<TrashIcon className="size-4" />
<span>Delete</span>
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to delete this project?
</AlertDialogTitle>
{!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
You have active services, please
delete them first
</span>
</div>
) : (
<AlertDialogDescription>
This action cannot be undone
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!emptyServices}
onClick={async () => {
await mutateAsync({
projectId: project.projectId,
})
.then(() => {
toast.success(
"Project delete succesfully"
);
})
.catch(() => {
toast.error(
"Error to delete this project"
);
})
.finally(() => {
utils.project.all.invalidate();
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
<span>
{totalServices}{" "}
{totalServices === 1 ? "service" : "services"}
</span>
</div>
</CardFooter>
</Card>
</Link>
</div>
);
})}
</div>
</>
);
};

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -97,9 +98,26 @@ export const DeleteRedis = ({ redisId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -49,8 +49,11 @@ export const columns: ColumnDef<LogEntry>[] = [
const log = row.original;
return (
<div className=" flex flex-col gap-2">
<div className="flex flex-row gap-3 ">
<div className="flex items-center flex-row gap-3 ">
{log.RequestMethod}{" "}
<div className="inline-flex items-center gap-2 bg-muted p-1 rounded">
<span>{log.RequestAddr}</span>
</div>
{log.RequestPath.length > 100
? `${log.RequestPath.slice(0, 82)}...`
: log.RequestPath}

View File

@@ -0,0 +1,189 @@
"use client";
import React from "react";
import {
Command,
CommandEmpty,
CommandList,
CommandGroup,
CommandInput,
CommandItem,
CommandDialog,
CommandSeparator,
} from "@/components/ui/command";
import { useRouter } from "next/router";
import {
extractServices,
type Services,
} from "@/pages/dashboard/project/[projectId]";
import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { api } from "@/utils/api";
import { Badge } from "@/components/ui/badge";
import { StatusTooltip } from "../shared/status-tooltip";
type Project = Awaited<ReturnType<typeof findProjectById>>;
export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data } = api.project.all.useQuery();
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<div>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder={"Search projects or settings"}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
No projects added yet. Click on Create project.
</CommandEmpty>
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Services"}>
<CommandList>
{data?.map((project) => {
const applications: Services[] = extractServices(project);
return applications.map((application) => (
<CommandItem
key={application.id}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`
);
setOpen(false);
}}
>
{application.type === "postgres" && (
<PostgresqlIcon className="h-6 w-6 mr-2" />
)}
{application.type === "redis" && (
<RedisIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mariadb" && (
<MariadbIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mongo" && (
<MongodbIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mysql" && (
<MysqlIcon className="h-6 w-6 mr-2" />
)}
{application.type === "application" && (
<GlobeIcon className="h-6 w-6 mr-2" />
)}
{application.type === "compose" && (
<CircuitBoard className="h-6 w-6 mr-2" />
)}
<span className="flex-grow">
{project.name} / {application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div>
</span>
<div>
<StatusTooltip status={application.status} />
</div>
</CommandItem>
));
})}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/projects");
setOpen(false);
}}
>
Projects
</CommandItem>
{!isCloud && (
<>
<CommandItem
onSelect={() => {
router.push("/dashboard/monitoring");
setOpen(false);
}}
>
Monitoring
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/traefik");
setOpen(false);
}}
>
Traefik
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/docker");
setOpen(false);
}}
>
Docker
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/requests");
setOpen(false);
}}
>
Requests
</CommandItem>
</>
)}
<CommandItem
onSelect={() => {
router.push("/dashboard/settings/server");
setOpen(false);
}}
>
Settings
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
);
};

View File

@@ -27,6 +27,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Languages } from "@/lib/languages";
import useLocale from "@/utils/hooks/use-locale";
import { useTranslation } from "next-i18next";
import { useTheme } from "next-themes";
@@ -37,12 +38,9 @@ const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark", "system"], {
required_error: "Please select a theme.",
}),
language: z.enum(
["en", "pl", "ru", "fr", "de", "tr", "zh-Hant", "zh-Hans", "fa"],
{
required_error: "Please select a language.",
},
),
language: z.nativeEnum(Languages, {
required_error: "Please select a language.",
}),
});
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
@@ -50,7 +48,7 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: "system",
language: "en",
language: Languages.English,
};
export function AppearanceForm() {
@@ -175,24 +173,15 @@ export function AppearanceForm() {
<SelectValue placeholder="No preset selected" />
</SelectTrigger>
<SelectContent>
{[
{ label: "English", value: "en" },
{ label: "Polski", value: "pl" },
{ label: "Русский", value: "ru" },
{ label: "Français", value: "fr" },
{ label: "Deutsch", value: "de" },
{ label: "繁體中文", value: "zh-Hant" },
{ label: "简体中文", value: "zh-Hans" },
{ label: "Türkçe", value: "tr" },
{
label: "Persian",
value: "fa",
},
].map((preset) => (
<SelectItem key={preset.label} value={preset.value}>
{preset.label}
</SelectItem>
))}
{Object.keys(Languages).map((preset) => {
const value =
Languages[preset as keyof typeof Languages];
return (
<SelectItem key={value} value={value}>
{preset}
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormItem>

View File

@@ -107,7 +107,24 @@ export const AddGithubProvider = () => {
/>
<br />
<div className="flex w-full justify-end">
<div className="flex w-full items-center justify-between">
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
isOrganization && !organizationName
? "pointer-events-none opacity-50"
: ""
}`}
target="_blank"
rel="noopener noreferrer"
>
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
type="submit"

View File

@@ -33,6 +33,9 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
gitlabUrl: z.string().min(1, {
message: "GitLab URL is required",
}),
applicationId: z.string().min(1, {
message: "Application ID is required",
}),
@@ -62,16 +65,22 @@ export const AddGitlabProvider = () => {
applicationSecret: "",
groupName: "",
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
},
resolver: zodResolver(Schema),
});
const gitlabUrl = form.watch("gitlabUrl");
useEffect(() => {
form.reset({
applicationId: "",
applicationSecret: "",
groupName: "",
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
});
}, [form, isOpen]);
@@ -83,6 +92,7 @@ export const AddGitlabProvider = () => {
authId: auth?.id || "",
name: data.name || "",
redirectUri: data.redirectUri || "",
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -129,7 +139,7 @@ export const AddGitlabProvider = () => {
<li className="flex flex-row gap-2 items-center">
Go to your GitLab profile settings{" "}
<Link
href="https://gitlab.com/-/profile/applications"
href={`${gitlabUrl}/-/profile/applications`}
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
@@ -169,6 +179,20 @@ export const AddGitlabProvider = () => {
)}
/>
<FormField
control={form.control}
name="gitlabUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitlab URL</FormLabel>
<FormControl>
<Input placeholder="https://gitlab.com/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"

View File

@@ -30,6 +30,9 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
gitlabUrl: z.string().url({
message: "Invalid Gitlab URL",
}),
groupName: z.string().optional(),
});
@@ -40,7 +43,7 @@ interface Props {
}
export const EditGitlabProvider = ({ gitlabId }: Props) => {
const { data: gitlab } = api.gitlab.one.useQuery(
const { data: gitlab, refetch } = api.gitlab.one.useQuery(
{
gitlabId,
},
@@ -57,6 +60,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
defaultValues: {
groupName: "",
name: "",
gitlabUrl: "https://gitlab.com",
},
resolver: zodResolver(Schema),
});
@@ -67,6 +71,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
form.reset({
groupName: gitlab?.groupName || "",
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
});
}, [form, isOpen]);
@@ -76,11 +81,13 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
gitProviderId: gitlab?.gitProviderId || "",
groupName: data.groupName || "",
name: data.name || "",
gitlabUrl: data.gitlabUrl || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
toast.success("Gitlab updated successfully");
setIsOpen(false);
refetch();
})
.catch(() => {
toast.error("Error to update Gitlab");
@@ -126,6 +133,19 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="gitlabUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitlab Url</FormLabel>
<FormControl>
<Input placeholder="https://gitlab.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}

View File

@@ -23,12 +23,16 @@ export const ShowGitProviders = () => {
const url = useUrl();
const getGitlabUrl = (clientId: string, gitlabId: string) => {
const getGitlabUrl = (
clientId: string,
gitlabId: string,
gitlabUrl: string,
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `https://gitlab.com/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;
};
@@ -142,6 +146,7 @@ export const ShowGitProviders = () => {
href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl,
)}
target="_blank"
className={buttonVariants({

View File

@@ -667,7 +667,7 @@ export const AddNotification = () => {
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when a dokploy is restarted.
Trigger the action when dokploy is restarted.
</FormDescription>
</div>
<FormControl>

View File

@@ -26,10 +26,12 @@ import { toast } from "sonner";
import { z } from "zod";
import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
import { AlertBlock } from "@/components/shared/alert-block";
const profileSchema = z.object({
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
});
@@ -52,7 +54,8 @@ const randomImages = [
export const ProfileForm = () => {
const { data, refetch } = api.auth.get.useQuery();
const { mutateAsync, isLoading } = api.auth.update.useMutation();
const { mutateAsync, isLoading, isError, error } =
api.auth.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
@@ -68,6 +71,7 @@ export const ProfileForm = () => {
email: data?.email || "",
password: "",
image: data?.image || "",
currentPassword: "",
},
resolver: zodResolver(profileSchema),
});
@@ -78,6 +82,7 @@ export const ProfileForm = () => {
email: data?.email || "",
password: "",
image: data?.image || "",
currentPassword: "",
});
if (data.email) {
@@ -94,6 +99,7 @@ export const ProfileForm = () => {
email: values.email.toLowerCase(),
password: values.password,
image: values.image,
currentPassword: values.currentPassword,
})
.then(async () => {
await refetch();
@@ -116,6 +122,8 @@ export const ProfileForm = () => {
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<div className="space-y-4">
@@ -135,6 +143,24 @@ export const ProfileForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"

View File

@@ -0,0 +1,130 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { DialogAction } from "@/components/shared/dialog-action";
import { AlertBlock } from "@/components/shared/alert-block";
import { useRouter } from "next/router";
const profileSchema = z.object({
password: z.string().min(1, {
message: "Password is required",
}),
});
type Profile = z.infer<typeof profileSchema>;
export const RemoveSelfAccount = () => {
const { data } = api.auth.get.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.auth.removeSelfAccount.useMutation();
const { t } = useTranslation("settings");
const router = useRouter();
const form = useForm<Profile>({
defaultValues: {
password: "",
},
resolver: zodResolver(profileSchema),
});
useEffect(() => {
if (data) {
form.reset({
password: "",
});
}
form.reset();
}, [form, form.reset, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
password: values.password,
})
.then(async () => {
toast.success("Profile Deleted");
router.push("/");
})
.catch(() => {});
};
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">Remove Self Account</CardTitle>
<CardDescription>
If you want to remove your account, you can do it here
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.password")}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<div>
<DialogAction
title="Are you sure you want to remove your account?"
description="This action cannot be undone, all your projects/servers will be deleted."
onClick={() => form.handleSubmit(onSubmit)()}
>
<Button type="button" isLoading={isLoading} variant="destructive">
Remove
</Button>
</DialogAction>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,167 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { FileTerminal } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
serverId: string;
}
const schema = z.object({
command: z.string().min(1, {
message: "Command is required",
}),
});
type Schema = z.infer<typeof schema>;
export const EditScript = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const { mutateAsync, isLoading } = api.server.update.useMutation();
const { data: defaultCommand } = api.server.getDefaultCommand.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const form = useForm<Schema>({
defaultValues: {
command: "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (server) {
form.reset({
command: server.command || defaultCommand,
});
}
}, [server, defaultCommand]);
const onSubmit = async (formData: Schema) => {
if (server) {
await mutateAsync({
...server,
command: formData.command || "",
serverId,
})
.then((data) => {
toast.success("Script modified successfully");
})
.catch(() => {
toast.error("Error modifying the script");
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
Modify Script
<FileTerminal className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl overflow-x-hidden">
<DialogHeader>
<DialogTitle>Modify Script</DialogTitle>
<DialogDescription>
Modify the script which install everything necessary to deploy
applications on your server,
</DialogDescription>
<AlertBlock type="warning">
We recommend not modifying this script unless you know what you are doing.
</AlertBlock>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-application"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl className="max-h-[75vh] max-w-[60rem] overflow-y-scroll overflow-x-hidden">
<CodeEditor
language="shell"
wrapperClassName="font-mono"
{...field}
placeholder={`
set -e
echo "Hello world"
`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter className="flex justify-between w-full">
<Button
variant="secondary"
onClick={() => {
form.reset({
command: defaultCommand || "",
});
}}
>
Reset
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-application"
type="submit"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -262,16 +262,16 @@ export function StatusRow({
<div className="flex items-center gap-2">
{showIcon ? (
<>
{isEnabled ? (
<CheckCircle2 className="size-4 text-green-500" />
) : (
<XCircle className="size-4 text-red-500" />
)}
<span
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
>
{description || (isEnabled ? "Installed" : "Not Installed")}
</span>
{isEnabled ? (
<CheckCircle2 className="size-4 text-green-500" />
) : (
<XCircle className="size-4 text-red-500" />
)}
</>
) : (
<span className="text-sm text-muted-foreground">{value}</span>

View File

@@ -0,0 +1,233 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Loader2, LockKeyhole, RefreshCw } from "lucide-react";
import { useState } from "react";
import { StatusRow } from "./gpu-support";
interface Props {
serverId: string;
}
export const SecurityAudit = ({ serverId }: Props) => {
const [isRefreshing, setIsRefreshing] = useState(false);
const { data, refetch, error, isLoading, isError } =
api.server.security.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const utils = api.useUtils();
return (
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<LockKeyhole className="size-5" />
<CardTitle className="text-xl">
Setup Security Sugestions
</CardTitle>
</div>
<CardDescription>Check the security sugestions</CardDescription>
</div>
<Button
isLoading={isRefreshing}
onClick={async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="flex items-center gap-2 w-full">
{isError && (
<AlertBlock type="error" className="w-full">
{error.message}
</AlertBlock>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info" className="w-full">
Ubuntu/Debian OS support is currently supported (Experimental)
</AlertBlock>
{isLoading ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">UFW</h3>
<p className="text-sm text-muted-foreground mb-4">
UFW (Uncomplicated Firewall) is a simple firewall that can
be used to block incoming and outgoing traffic from your
server.
</p>
<div className="grid gap-2.5">
<StatusRow
label="UFW Installed"
isEnabled={data?.ufw?.installed}
description={
data?.ufw?.installed
? "Installed (Recommended)"
: "Not Installed (UFW should be installed for security)"
}
/>
<StatusRow
label="Status"
isEnabled={data?.ufw?.active}
description={
data?.ufw?.active
? "Active (Recommended)"
: "Not Active (UFW should be enabled for security)"
}
/>
<StatusRow
label="Default Incoming"
isEnabled={data?.ufw?.defaultIncoming === "deny"}
description={
data?.ufw?.defaultIncoming === "deny"
? "Default: Deny (Recommended)"
: `Default: ${data?.ufw?.defaultIncoming} (Should be set to 'deny' for security)`
}
/>
</div>
</div>
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">SSH</h3>
<p className="text-sm text-muted-foreground mb-4">
SSH (Secure Shell) is a protocol that allows you to securely
connect to a server and execute commands on it.
</p>
<div className="grid gap-2.5">
<StatusRow
label="Enabled"
isEnabled={data?.ssh.enabled}
description={
data?.ssh.enabled
? "Enabled"
: "Not Enabled (SSH should be enabled)"
}
/>
<StatusRow
label="Key Auth"
isEnabled={data?.ssh.keyAuth}
description={
data?.ssh.keyAuth
? "Enabled (Recommended)"
: "Not Enabled (Key Authentication should be enabled)"
}
/>
<StatusRow
label="Password Auth"
isEnabled={data?.ssh.passwordAuth === "no"}
description={
data?.ssh.passwordAuth === "no"
? "Disabled (Recommended)"
: "Enabled (Password Authentication should be disabled)"
}
/>
<StatusRow
label="Permit Root Login"
isEnabled={data?.ssh.permitRootLogin === "no"}
description={
data?.ssh.permitRootLogin === "no"
? "Disabled (Recommended)"
: `Enabled: ${data?.ssh.permitRootLogin} (Root Login should be disabled)`
}
/>
<StatusRow
label="Use PAM"
isEnabled={data?.ssh.usePam === "no"}
description={
data?.ssh.usePam === "no"
? "Disabled (Recommended for key-based auth)"
: "Enabled (Should be disabled when using key-based auth)"
}
/>
</div>
</div>
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Fail2Ban</h3>
<p className="text-sm text-muted-foreground mb-4">
Fail2Ban (Fail2Ban) is a service that can be used to prevent
brute force attacks on your server.
</p>
<div className="grid gap-2.5">
<StatusRow
label="Installed"
isEnabled={data?.fail2ban.installed}
description={
data?.fail2ban.installed
? "Installed (Recommended)"
: "Not Installed (Fail2Ban should be installed for protection against brute force attacks)"
}
/>
<StatusRow
label="Enabled"
isEnabled={data?.fail2ban.enabled}
description={
data?.fail2ban.enabled
? "Enabled (Recommended)"
: "Not Enabled (Fail2Ban service should be enabled)"
}
/>
<StatusRow
label="Active"
isEnabled={data?.fail2ban.active}
description={
data?.fail2ban.active
? "Active (Recommended)"
: "Not Active (Fail2Ban service should be running)"
}
/>
<StatusRow
label="SSH Protection"
isEnabled={data?.fail2ban.sshEnabled === "true"}
description={
data?.fail2ban.sshEnabled === "true"
? "Enabled (Recommended)"
: "Not Enabled (SSH protection should be enabled to prevent brute force attacks)"
}
/>
<StatusRow
label="SSH Mode"
isEnabled={data?.fail2ban.sshMode === "aggressive"}
description={
data?.fail2ban.sshMode === "aggressive"
? "Aggressive Mode (Recommended)"
: `Mode: ${data?.fail2ban.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</CardContent>
);
};

View File

@@ -32,7 +32,10 @@ import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment";
import { EditScript } from "./edit-script";
import { GPUSupport } from "./gpu-support";
import { ValidateServer } from "./validate-server";
import { SecurityAudit } from "./security-audit";
interface Props {
serverId: string;
@@ -88,11 +91,18 @@ export const SetupServer = ({ serverId }: Props) => {
</AlertBlock>
</div>
) : (
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
<div id="hook-form-add-gitlab" className="grid w-full gap-4">
<AlertBlock type="warning">
Using a root user is required to ensure everything works as
expected.
</AlertBlock>
<Tabs defaultValue="ssh-keys">
<TabsList className="grid grid-cols-3 w-[400px]">
<TabsList className="grid grid-cols-5 w-[700px]">
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="audit">Security</TabsTrigger>
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
@@ -137,7 +147,7 @@ export const SetupServer = ({ serverId }: Props) => {
Automatic process
</span>
<Link
href="https://docs.dokploy.com/en/docs/core/get-started/introduction"
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2"
>
@@ -196,6 +206,28 @@ export const SetupServer = ({ serverId }: Props) => {
</li>
</ul>
</div>
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Supported Distros:
</span>
<p>
We strongly recommend to use the following distros to
ensure the best experience:
</p>
<ul>
<li>1. Ubuntu 24.04 LTS</li>
<li>2. Ubuntu 23.10 LTS </li>
<li>3. Ubuntu 22.04 LTS</li>
<li>4. Ubuntu 20.04 LTS</li>
<li>5. Ubuntu 18.04 LTS</li>
<li>6. Debian 12</li>
<li>7. Debian 11</li>
<li>8. Debian 10</li>
<li>9. Fedora 40</li>
<li>10. Centos 9</li>
<li>11. Centos 8</li>
</ul>
</div>
</div>
</TabsContent>
<TabsContent value="deployments">
@@ -203,7 +235,7 @@ export const SetupServer = ({ serverId }: Props) => {
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">
Deployments
@@ -212,24 +244,29 @@ export const SetupServer = ({ serverId }: Props) => {
See all the 5 Server Setup
</CardDescription>
</div>
<DialogAction
title={"Setup Server?"}
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.then(async () => {
refetch();
toast.success("Server setup successfully");
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<DialogAction
title={"Setup Server?"}
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.catch(() => {
toast.error("Error configuring server");
});
}}
>
<Button isLoading={isLoading}>Setup Server</Button>
</DialogAction>
.then(async () => {
refetch();
toast.success("Server setup successfully");
})
.catch(() => {
toast.error("Error configuring server");
});
}}
>
<Button isLoading={isLoading}>
Setup Server
</Button>
</DialogAction>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -293,6 +330,22 @@ export const SetupServer = ({ serverId }: Props) => {
</div>
</CardContent>
</TabsContent>
<TabsContent
value="validate"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<ValidateServer serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"

View File

@@ -31,8 +31,12 @@ import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server";
import { useRouter } from "next/router";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const router = useRouter();
const query = router.query;
const { data, refetch } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
@@ -42,12 +46,26 @@ export const ShowServers = () => {
return (
<div className="p-6 space-y-6">
{query?.success && isCloud && <WelcomeSuscription />}
<div className="space-y-2 flex flex-row justify-between items-end">
<div>
<h1 className="text-2xl font-bold">Servers</h1>
<p className="text-muted-foreground">
Add servers to deploy your applications remotely.
</p>
<div className="flex flex-col gap-2">
<div>
<h1 className="text-2xl font-bold">Servers</h1>
<p className="text-muted-foreground">
Add servers to deploy your applications remotely.
</p>
</div>
{isCloud && (
<span
className="text-primary cursor-pointer text-sm"
onClick={() => {
router.push("/dashboard/settings/servers?success=true");
}}
>
Reset Onboarding
</span>
)}
</div>
{sshKeys && sshKeys?.length > 0 && (
@@ -100,7 +118,9 @@ export const ShowServers = () => {
{data && data?.length > 0 && (
<div className="flex flex-col gap-6 overflow-auto">
<Table>
<TableCaption>See all servers</TableCaption>
<TableCaption>
<div className="flex flex-col gap-4">See all servers</div>
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>

View File

@@ -0,0 +1,151 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { useState } from "react";
import { StatusRow } from "./gpu-support";
interface Props {
serverId: string;
}
export const ValidateServer = ({ serverId }: Props) => {
const [isRefreshing, setIsRefreshing] = useState(false);
const { data, refetch, error, isLoading, isError } =
api.server.validate.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const utils = api.useUtils();
return (
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<PcCase className="size-5" />
<CardTitle className="text-xl">Setup Validation</CardTitle>
</div>
<CardDescription>
Check if your server is ready for deployment
</CardDescription>
</div>
<Button
isLoading={isRefreshing}
onClick={async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="flex items-center gap-2 w-full">
{isError && (
<AlertBlock type="error" className="w-full">
{error.message}
</AlertBlock>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isLoading ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
</p>
<div className="grid gap-2.5">
<StatusRow
label="Docker Installed"
isEnabled={data?.docker?.enabled}
description={
data?.docker?.enabled
? `Installed: ${data?.docker?.version}`
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
description={
data?.nixpacks?.enabled
? `Installed: ${data?.nixpacks?.version}`
: undefined
}
/>
<StatusRow
label="Buildpacks Installed"
isEnabled={data?.buildpacks?.enabled}
description={
data?.buildpacks?.enabled
? `Installed: ${data?.buildpacks?.version}`
: undefined
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
description={
data?.isMainDirectoryInstalled
? "Created"
: "Not Created"
}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</CardContent>
);
};

View File

@@ -0,0 +1,284 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DialogFooter } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
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 Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
ipAddress: z.string().min(1, {
message: "IP Address is required",
}),
port: z.number().optional(),
username: z.string().optional(),
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
});
type Schema = z.infer<typeof Schema>;
interface Props {
stepper: any;
}
export const CreateServer = ({ stepper }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
api.stripe.canCreateMoreServers.useQuery();
const { mutateAsync, error, isError } = api.server.create.useMutation();
const cloudSSHKey = sshKeys?.find(
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
);
const form = useForm<Schema>({
defaultValues: {
description: "Dokploy Cloud Server",
name: "My First Server",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: cloudSSHKey?.sshKeyId || "",
},
resolver: zodResolver(Schema),
});
useEffect(() => {
form.reset({
description: "Dokploy Cloud Server",
name: "My First Server",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: cloudSSHKey?.sshKeyId || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
useEffect(() => {
refetch();
}, [isOpen]);
const onSubmit = async (data: Schema) => {
await mutateAsync({
name: data.name,
description: data.description || "",
ipAddress: data.ipAddress || "",
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
})
.then(async (data) => {
toast.success("Server Created");
stepper.next();
})
.catch(() => {
toast.error("Error to create a server");
});
};
return (
<Card className="bg-background flex flex-col gap-4">
<div className="flex flex-col gap-2 pt-5 px-4">
{!canCreateMoreServers && (
<AlertBlock type="warning">
You cannot create more servers,{" "}
<Link href="/dashboard/settings/billing" className="text-primary">
Please upgrade your plan
</Link>
</AlertBlock>
)}
</div>
<CardContent className="flex flex-col">
<Form {...form}>
<form
id="hook-form-add-server"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4 ">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Hostinger Server" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="This server is for databases..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sshKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a SSH Key</FormLabel>
{!cloudSSHKey && (
<AlertBlock>
Looks like you didn't have the SSH Key yet, you can create
one{" "}
<Link
href="/dashboard/settings/ssh-keys"
className="text-primary"
>
here
</Link>
</AlertBlock>
)}
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a SSH Key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectLabel>
Registries ({sshKeys?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
placeholder="22"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter className="pt-5">
<Button
isLoading={form.formState.isSubmitting}
disabled={!canCreateMoreServers}
form="hook-form-add-server"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,152 @@
import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/utils/api";
import { ExternalLinkIcon, Loader2 } from "lucide-react";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import Link from "next/link";
export const CreateSSHKey = () => {
const { data, refetch } = api.sshKey.all.useQuery();
const generateMutation = api.sshKey.generate.useMutation();
const { mutateAsync, isLoading } = api.sshKey.create.useMutation();
const hasCreatedKey = useRef(false);
const cloudSSHKey = data?.find(
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
);
useEffect(() => {
const createKey = async () => {
if (!data || cloudSSHKey || hasCreatedKey.current || isLoading) {
return;
}
hasCreatedKey.current = true;
try {
const keys = await generateMutation.mutateAsync({
type: "rsa",
});
await mutateAsync({
name: "dokploy-cloud-ssh-key",
description: "Used on Dokploy Cloud",
privateKey: keys.privateKey,
publicKey: keys.publicKey,
});
await refetch();
} catch (error) {
console.error("Error creating SSH key:", error);
hasCreatedKey.current = false;
}
};
createKey();
}, [data]);
return (
<Card className="h-full bg-transparent">
<CardContent>
<div className="grid w-full gap-4 pt-4">
{isLoading || !cloudSSHKey ? (
<div className="min-h-[25vh] justify-center flex items-center gap-4">
<Loader2
className="animate-spin text-muted-foreground"
size={32}
/>
</div>
) : (
<>
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
<p className="text-primary text-base font-semibold">
You have two options to add SSH Keys to your server:
</p>
<ul>
<li>1. Add The SSH Key to Server Manually</li>
<li>
2. Add the public SSH Key when you create a server in your
preffered provider (Hostinger, Digital Ocean, Hetzner, etc){" "}
</li>
</ul>
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Option 1
</span>
<ul>
<li className="items-center flex gap-1">
1. Login to your server{" "}
</li>
<li>
2. When you are logged in run the following command
<div className="flex relative flex-col gap-4 w-full mt-2">
<CodeEditor
lineWrapping
language="properties"
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
readOnly
className="font-mono opacity-60"
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</li>
<li className="mt-1">
3. You're done, follow the next step to insert the details
of your server.
</li>
</ul>
</div>
<div className="flex flex-col gap-2 w-full mt-2 border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Option 2
</span>
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key
<button
type="button"
className=" right-2 top-8"
onClick={() => {
copy(
cloudSSHKey?.publicKey || "Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4 text-muted-foreground" />
</button>
</div>
</div>
</div>
<Link
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2"
>
View Tutorial <ExternalLinkIcon className="size-4" />
</Link>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,163 @@
import { ShowDeployment } from "@/components/dashboard/application/deployments/show-deployment";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { RocketIcon } from "lucide-react";
import { toast } from "sonner";
import { EditScript } from "../edit-script";
import { api } from "@/utils/api";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
} from "@/components/ui/select";
export const Setup = () => {
const { data: servers } = api.server.all.useQuery();
const [serverId, setServerId] = useState<string>(
servers?.[0]?.serverId || "",
);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const { mutateAsync, isLoading } = api.server.setup.useMutation();
return (
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>Select the server and click on setup server</Label>
<Select onValueChange={setServerId} defaultValue={serverId}>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>See all the 5 Server Setup</CardDescription>
</div>
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<DialogAction
title={"Setup Server?"}
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.then(async () => {
refetch();
toast.success("Server setup successfully");
})
.catch(() => {
toast.error("Error configuring server");
});
}}
>
<Button isLoading={isLoading}>Setup Server</Button>
</DialogAction>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[30vh]">
{server?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,189 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { api } from "@/utils/api";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
} from "@/components/ui/select";
import { StatusRow } from "../gpu-support";
import { AlertBlock } from "@/components/shared/alert-block";
export const Verify = () => {
const { data: servers } = api.server.all.useQuery();
const [serverId, setServerId] = useState<string>(
servers?.[0]?.serverId || "",
);
const { data, refetch, error, isLoading, isError } =
api.server.validate.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const [isRefreshing, setIsRefreshing] = useState(false);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
return (
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>Select a server</Label>
<Select onValueChange={setServerId} defaultValue={serverId}>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<PcCase className="size-5" />
<CardTitle className="text-xl">Setup Validation</CardTitle>
</div>
<CardDescription>
Check if your server is ready for deployment
</CardDescription>
</div>
<Button
isLoading={isRefreshing}
onClick={async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="flex items-center gap-2 w-full">
{isError && (
<AlertBlock type="error" className="w-full">
{error.message}
</AlertBlock>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
{isLoading ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
</p>
<div className="grid gap-2.5">
<StatusRow
label="Docker Installed"
isEnabled={data?.docker?.enabled}
description={
data?.docker?.enabled
? `Installed: ${data?.docker?.version}`
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
description={
data?.nixpacks?.enabled
? `Installed: ${data?.nixpacks?.version}`
: undefined
}
/>
<StatusRow
label="Buildpacks Installed"
isEnabled={data?.buildpacks?.enabled}
description={
data?.buildpacks?.enabled
? `Installed: ${data?.buildpacks?.version}`
: undefined
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
description={
data?.isMainDirectoryInstalled
? "Created"
: "Not Created"
}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</CardContent>
);
};

View File

@@ -0,0 +1,411 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { BookIcon, Puzzle } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { defineStepper } from "@stepperize/react";
import React from "react";
import { Separator } from "@/components/ui/separator";
import { AlertBlock } from "@/components/shared/alert-block";
import { CreateServer } from "./create-server";
import { CreateSSHKey } from "./create-ssh-key";
import { Setup } from "./setup";
import { Verify } from "./verify";
import { Database, Globe, GitMerge, Users, Code2, Plug } from "lucide-react";
import ConfettiExplosion from "react-confetti-explosion";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
export const { useStepper, steps, Scoped } = defineStepper(
{
id: "requisites",
title: "Requisites",
description: "Check your requisites",
},
{
id: "create-ssh-key",
title: "SSH Key",
description: "Create your ssh key",
},
{
id: "connect-server",
title: "Connect",
description: "Connect",
},
{ id: "setup", title: "Setup", description: "Setup your server" },
{ id: "verify", title: "Verify", description: "Verify your server" },
{ id: "complete", title: "Complete", description: "Checkout complete" },
);
export const WelcomeSuscription = () => {
const [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const { push } = useRouter();
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
if (!confettiShown) {
setShowConfetti(true);
localStorage.setItem("hasShownConfetti", "true");
}
}, [showConfetti]);
return (
<Dialog open={isOpen}>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl min-h-[75vh]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">
{showConfetti && (
<ConfettiExplosion
duration={3000}
force={0.3}
particleSize={12}
particleCount={300}
className="z-[9999]"
zIndex={9999}
width={1500}
/>
)}
</div>
<DialogHeader>
<DialogTitle className="text-2xl text-center">
Welcome To Dokploy Cloud 🎉
</DialogTitle>
<DialogDescription className="text-center max-w-xl mx-auto">
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
onboard. Before you dive in, you'll need to configure your remote
server to unlock all the features we offer.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex justify-between">
<h2 className="text-lg font-semibold">Steps</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Step {stepper.current.index + 1} of {steps.length}
</span>
<div />
</div>
</div>
<Scoped>
<nav aria-label="Checkout Steps" className="group my-4">
<ol
className="flex items-center justify-between gap-2"
aria-orientation="horizontal"
>
{stepper.all.map((step, index, array) => (
<React.Fragment key={step.id}>
<li className="flex items-center gap-4 flex-shrink-0">
<Button
type="button"
role="tab"
variant={
index <= stepper.current.index ? "secondary" : "ghost"
}
aria-current={
stepper.current.id === step.id ? "step" : undefined
}
aria-posinset={index + 1}
aria-setsize={steps.length}
aria-selected={stepper.current.id === step.id}
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
onClick={() => stepper.goTo(step.id)}
>
{index + 1}
</Button>
<span className="text-sm font-medium">{step.title}</span>
</li>
{index < array.length - 1 && (
<Separator
className={`flex-1 ${
index < stepper.current.index
? "bg-primary"
: "bg-muted"
}`}
/>
)}
</React.Fragment>
))}
</ol>
</nav>
{stepper.switch({
requisites: () => (
<div className="flex flex-col gap-2 border p-4 rounded-lg">
<span className="text-primary text-base font-bold">
Before getting started, please follow the steps below to
ensure the best experience:
</span>
<div>
<p className="text-primary text-sm font-medium">
Supported Distributions:
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>Ubuntu 24.04 LTS</li>
<li>Ubuntu 23.10</li>
<li>Ubuntu 22.04 LTS</li>
<li>Ubuntu 20.04 LTS</li>
<li>Ubuntu 18.04 LTS</li>
<li>Debian 12</li>
<li>Debian 11</li>
<li>Debian 10</li>
<li>Fedora 40</li>
<li>CentOS 9</li>
<li>CentOS 8</li>
</ul>
</div>
<div>
<p className="text-primary text-sm font-medium">
You will need to purchase or rent a Virtual Private Server
(VPS) to proceed, we recommend to use one of these
providers since has been heavily tested.
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>
<a
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
className="text-link underline"
>
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"
className="text-link underline"
>
DigitalOcean - Get $200 Credits
</a>
</li>
<li>
<a
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
className="text-link underline"
>
Hetzner - Get 20 Credits
</a>
</li>
<li>
<a
href="https://www.vultr.com/?ref=9679828"
className="text-link underline"
>
Vultr
</a>
</li>
<li>
<a
href="https://www.linode.com/es/pricing/#compute-shared"
className="text-link underline"
>
Linode
</a>
</li>
</ul>
<AlertBlock className="mt-4 px-4">
You are free to use whatever provider, but we recommend to
use one of the above, to avoid issues.
</AlertBlock>
</div>
</div>
),
"create-ssh-key": () => <CreateSSHKey />,
"connect-server": () => <CreateServer stepper={stepper} />,
setup: () => <Setup />,
verify: () => <Verify />,
complete: () => {
const features = [
{
title: "Scalable Deployments",
description:
"Deploy and scale your applications effortlessly to handle any workload.",
icon: <Database className="text-primary" />,
},
{
title: "Automated Backups",
description: "Protect your data with automatic backups",
icon: <Database className="text-primary" />,
},
{
title: "Open Source Templates",
description:
"Big list of common open source templates in one-click",
icon: <Puzzle className="text-primary" />,
},
{
title: "Custom Domains",
description:
"Link your own domains to your applications for a professional presence.",
icon: <Globe className="text-primary" />,
},
{
title: "CI/CD Integration",
description:
"Implement continuous integration and deployment workflows to streamline development.",
icon: <GitMerge className="text-primary" />,
},
{
title: "Database Management",
description:
"Efficiently manage your databases with intuitive tools.",
icon: <Database className="text-primary" />,
},
{
title: "Team Collaboration",
description:
"Collaborate with your team on shared projects with customizable permissions.",
icon: <Users className="text-primary" />,
},
{
title: "Multi-language Support",
description:
"Deploy applications in multiple programming languages to suit your needs.",
icon: <Code2 className="text-primary" />,
},
{
title: "API Access",
description:
"Integrate and manage your applications via robust and well-documented APIs.",
icon: <Plug className="text-primary" />,
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">You're All Set!</h2>
<p className=" text-muted-foreground">
Did you know you can deploy any number of applications
that your server can handle?
</p>
<p className="text-muted-foreground">
Here are some of the things you can do with Dokploy
Cloud:
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{features.map((feature, index) => (
<div
key={index}
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
>
<div className="text-3xl mb-2">{feature.icon}</div>
<h3 className="text-lg font-medium mb-1">
{feature.title}
</h3>
<p className="text-sm text-muted-foreground">
{feature.description}
</p>
</div>
))}
</div>
<div className="flex flex-col gap-2 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
</span>
<span className="text-sm text-muted-foreground">
Join to our Discord server and we will help you.
</span>
<div className="flex flex-row gap-4">
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Join Discord
</Link>
</Button>
<Button className="rounded-full w-fit">
<Link
href="https://github.com/Dokploy/dokploy"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 "
>
<GithubIcon />
Github
</Link>
</Button>
<Button
className="rounded-full w-fit"
variant="outline"
>
<Link
href="https://docs.dokploy.com/docs/core"
aria-label="Dokploy Docs"
target="_blank"
className="flex flex-row items-center gap-2 "
>
<BookIcon size={16} />
Docs
</Link>
</Button>
</div>
</div>
</div>
);
},
})}
</Scoped>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
{!stepper.isLast && (
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
push("/dashboard/settings/servers");
}}
>
Skip for now
</Button>
)}
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
disabled={stepper.isFirst}
variant="secondary"
>
Back
</Button>
<Button
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/projects");
} else {
stepper.next();
}
}}
>
{stepper.isLast ? "Complete" : "Next"}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,7 +1,9 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -49,14 +51,34 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
},
);
const [containerId, setContainerId] = useState<string | undefined>();
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const handleMainDialogOpenChange = (open: boolean) => {
if (!open) {
setConfirmDialogOpen(true);
} else {
setMainDialogOpen(true);
}
};
const handleConfirm = () => {
setConfirmDialogOpen(false);
setMainDialogOpen(false);
};
const handleCancel = () => {
setConfirmDialogOpen(false);
};
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
<Dialog>
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
<DialogHeader>
@@ -96,6 +118,24 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
id="terminal"
containerId={containerId || "select-a-container"}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Are you sure you want to close the terminal?
</DialogTitle>
<DialogDescription>
By clicking the confirm button, the terminal will be closed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);

View File

@@ -91,11 +91,7 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
</SelectGroup>
</SelectContent>
</Select>
<DockerLogsId
id="terminal"
containerId={containerId || ""}
serverId={serverId}
/>
<DockerLogsId containerId={containerId || ""} serverId={serverId} />
</div>
</DialogContent>
</Dialog>

View File

@@ -20,13 +20,11 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}
const term = new XTerm({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: "transparent",
background: "#19191A",
background: "transparent",
},
});
const addonFit = new FitAddon();
@@ -40,6 +38,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
@@ -50,7 +49,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
return (
<div className="flex flex-col gap-4">
<div className="w-full h-full bg-input rounded-lg p-2 ">
<div className="w-full h-full bg-transparent border rounded-lg p-2 ">
<div id={id} ref={termRef} className="rounded-xl" />
</div>
</div>

View File

@@ -1,25 +1,34 @@
import Head from "next/head";
import { Navbar } from "./navbar";
import { NavigationTabs, type TabState } from "./navigation-tabs";
interface Props {
children: React.ReactNode;
tab: TabState;
metaName?: string;
}
export const DashboardLayout = ({ children, tab }: Props) => {
export const DashboardLayout = ({ children, tab, metaName }: Props) => {
return (
<div>
<div
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
id="app-container"
>
<Navbar />
<main className="pt-6 flex w-full flex-col items-center">
<div className="w-full max-w-8xl px-4 lg:px-8">
<NavigationTabs tab={tab}>{children}</NavigationTabs>
</div>
</main>
<>
<Head>
<title>
{metaName ?? tab.charAt(0).toUpperCase() + tab.slice(1)} | Dokploy
</title>
</Head>
<div>
<div
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
id="app-container"
>
<Navbar />
<main className="pt-6 flex w-full flex-col items-center">
<div className="w-full max-w-8xl px-4 lg:px-8">
<NavigationTabs tab={tab}>{children}</NavigationTabs>
</div>
</main>
</div>
</div>
</div>
</>
);
};

View File

@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
{
title: "Registry",
label: "",
icon: ListMusic,
icon: GalleryVerticalEnd,
href: "/dashboard/settings/registry",
},
@@ -150,6 +150,7 @@ import {
BoxesIcon,
CreditCardIcon,
Database,
GalleryVerticalEnd,
GitBranch,
KeyIcon,
KeyRound,

View File

@@ -2,7 +2,9 @@ import { cn } from "@/lib/utils";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
@@ -10,14 +12,16 @@ import { useTheme } from "next-themes";
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties";
language?: "yaml" | "json" | "properties" | "shell";
lineWrapping?: boolean;
lineNumbers?: boolean;
}
export const CodeEditor = ({
className,
wrapperClassName,
language = "yaml",
lineNumbers = true,
...props
}: Props) => {
const { resolvedTheme } = useTheme();
@@ -25,7 +29,7 @@ export const CodeEditor = ({
<div className={cn("relative overflow-auto", wrapperClassName)}>
<CodeMirror
basicSetup={{
lineNumbers: true,
lineNumbers,
foldGutter: true,
highlightSelectionMatches: true,
highlightActiveLine: !props.disabled,
@@ -37,7 +41,9 @@ export const CodeEditor = ({
? yaml()
: language === "json"
? json()
: StreamLanguage.define(properties),
: language === "shell"
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [],
]}
{...props}

View File

@@ -14,6 +14,16 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
red: "border-transparent select-none items-center whitespace-nowrap font-medium bg-red-500/15 text-destructive text-xs h-4 px-1 py-1 rounded-md",
yellow:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-yellow-500/15 text-yellow-500 text-xs h-4 px-1 py-1 rounded-md",
orange:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-orange-500/15 text-orange-500 text-xs h-4 px-1 py-1 rounded-md",
green:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-500/15 text-emerald-500 text-xs h-4 px-1 py-1 rounded-md",
blue: "border-transparent select-none items-center whitespace-nowrap font-medium bg-blue-500/15 text-blue-500 text-xs h-4 px-1 py-1 rounded-md",
blank:
"border-transparent select-none items-center whitespace-nowrap font-medium dark:bg-white/15 bg-black/15 text-foreground text-xs h-4 px-1 py-1 rounded-md",
outline: "text-foreground",
},
},

View File

@@ -29,7 +29,7 @@ export const Secrets = (props: Props) => {
return (
<>
<CardHeader className="flex flex-row w-full items-center justify-between">
<CardHeader className="flex flex-row w-full items-center justify-between px-0">
<div>
<CardTitle className="text-xl">{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
@@ -47,7 +47,7 @@ export const Secrets = (props: Props) => {
)}
</Toggle>
</CardHeader>
<CardContent className="w-full space-y-4">
<CardContent className="w-full space-y-4 p-0">
<FormField
control={form.control}
name={props.name}

View File

@@ -1,3 +1,5 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
@@ -9,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
@@ -17,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>

View File

@@ -9,6 +9,8 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipPortal = TooltipPrimitive.Portal;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
@@ -25,4 +27,10 @@ const TooltipContent = React.forwardRef<
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
TooltipPortal,
};

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "herokuVersion" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ALTER COLUMN "herokuVersion" SET DEFAULT '24';

View File

@@ -0,0 +1,53 @@
ALTER TYPE "domainType" ADD VALUE 'preview';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "preview_deployments" (
"previewDeploymentId" text PRIMARY KEY NOT NULL,
"branch" text NOT NULL,
"pullRequestId" text NOT NULL,
"pullRequestNumber" text NOT NULL,
"pullRequestURL" text NOT NULL,
"pullRequestTitle" text NOT NULL,
"pullRequestCommentId" text NOT NULL,
"previewStatus" "applicationStatus" DEFAULT 'idle' NOT NULL,
"appName" text NOT NULL,
"applicationId" text NOT NULL,
"domainId" text,
"createdAt" text NOT NULL,
"expiresAt" text,
CONSTRAINT "preview_deployments_appName_unique" UNIQUE("appName")
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewEnv" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewBuildArgs" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewWildcard" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewPort" integer DEFAULT 3000;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewHttps" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "certificateType" "certificateType" DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewLimit" integer DEFAULT 3;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "isPreviewDeploymentsActive" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "isPreviewDeployment" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_domainId_domain_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domain"("domainId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "domain" ADD CONSTRAINT "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "gitlab" ADD COLUMN "gitlabUrl" text DEFAULT 'https://gitlab.com' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "server" ADD COLUMN "command" text DEFAULT '' NOT NULL;

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

@@ -330,6 +330,41 @@
"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
}
]
}

View File

@@ -0,0 +1,18 @@
export enum Languages {
English = "en",
Polish = "pl",
Russian = "ru",
French = "fr",
German = "de",
ChineseTraditional = "zh-Hant",
ChineseSimplified = "zh-Hans",
Turkish = "tr",
Kazakh = "kz",
Persian = "fa",
Korean = "ko",
Portuguese = "pt-br",
Italian = "it",
Japanese = "ja",
}
export type Language = keyof typeof Languages;

View File

@@ -1,10 +0,0 @@
/** @type {import('next-i18next').UserConfig} */
module.exports = {
i18n: {
defaultLocale: "en",
locales: ["en", "pl", "ru", "fr", "de", "tr", "zh-Hant", "zh-Hans", "fa"],
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
};

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.13.1",
"version": "v0.15.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -11,7 +11,8 @@
"build-next": "next build",
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"dev": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",
@@ -34,6 +35,8 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"react-confetti-explosion":"2.1.2",
"@stepperize/react": "4.0.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1",

View File

@@ -1,6 +1,8 @@
import "@/styles/globals.css";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
import type { NextPage } from "next";
import { appWithTranslation } from "next-i18next";
@@ -32,10 +34,10 @@ const MyApp = ({
return (
<>
<style jsx global>{`
:root {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
:root {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
<Head>
<title>Dokploy</title>
</Head>
@@ -55,6 +57,7 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
</>
@@ -62,30 +65,13 @@ const MyApp = ({
};
export default api.withTRPC(
appWithTranslation(
MyApp,
// keep this in sync with next-i18next.config.js
// if you want to know why don't just import the config file, this because next-i18next.config.js must be a CJS, but the rest of the code is ESM.
// Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259
// if one day every page is translated, we can safely remove this config.
{
i18n: {
defaultLocale: "en",
locales: [
"en",
"pl",
"ru",
"fr",
"de",
"tr",
"zh-Hant",
"zh-Hans",
"fa",
],
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages),
localeDetection: false,
},
),
fallbackLng: "en",
keySeparator: false,
}),
);

View File

@@ -3,145 +3,259 @@ import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { IS_CLOUD } from "@dokploy/server";
import {
createPreviewDeployment,
type Domain,
findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { extractCommitMessage, extractHash } from "./[refreshToken]";
import { generateRandomDomain } from "@/templates/utils";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
req: NextApiRequest,
res: NextApiResponse,
) {
const signature = req.headers["x-hub-signature-256"];
const githubBody = req.body;
const signature = req.headers["x-hub-signature-256"];
const githubBody = req.body;
if (!githubBody?.installation?.id) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
if (!githubBody?.installation?.id) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
const githubResult = await db.query.github.findFirst({
where: eq(github.githubInstallationId, githubBody.installation.id),
});
const githubResult = await db.query.github.findFirst({
where: eq(github.githubInstallationId, githubBody.installation.id),
});
if (!githubResult) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
if (!githubResult) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
if (!githubResult.githubWebhookSecret) {
res.status(400).json({ message: "Github Webhook Secret not set" });
return;
}
const webhooks = new Webhooks({
secret: githubResult.githubWebhookSecret,
});
if (!githubResult.githubWebhookSecret) {
res.status(400).json({ message: "Github Webhook Secret not set" });
return;
}
const webhooks = new Webhooks({
secret: githubResult.githubWebhookSecret,
});
const verified = await webhooks.verify(
JSON.stringify(githubBody),
signature as string
);
const verified = await webhooks.verify(
JSON.stringify(githubBody),
signature as string,
);
if (!verified) {
res.status(401).json({ message: "Unauthorized" });
return;
}
if (!verified) {
res.status(401).json({ message: "Unauthorized" });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
}
if (req.headers["x-github-event"] !== "push") {
res.status(400).json({ message: "We only accept push events" });
return;
}
if (
req.headers["x-github-event"] !== "push" &&
req.headers["x-github-event"] !== "pull_request"
) {
res
.status(400)
.json({ message: "We only accept push events or pull_request events" });
return;
}
try {
const branchName = githubBody?.ref?.replace("refs/heads/", "");
const repository = githubBody?.repository?.name;
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
if (req.headers["x-github-event"] === "push") {
try {
const branchName = githubBody?.ref?.replace("refs/heads/", "");
const repository = githubBody?.repository?.name;
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = githubBody?.repository?.owner?.name;
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.branch, branchName),
eq(applications.repository, repository)
),
});
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.branch, branchName),
eq(applications.repository, repository),
eq(applications.owner, owner),
),
});
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
}
);
}
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const composeApps = await db.query.compose.findMany({
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.branch, branchName),
eq(compose.repository, repository)
),
});
const composeApps = await db.query.compose.findMany({
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.branch, branchName),
eq(compose.repository, repository),
eq(compose.owner, owner),
),
});
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
return true;
}
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
}
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const totalApps = apps.length + composeApps.length;
const emptyApps = totalApps === 0;
const totalApps = apps.length + composeApps.length;
const emptyApps = totalApps === 0;
if (emptyApps) {
res.status(200).json({ message: "No apps to deploy" });
return;
}
res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) {
res.status(400).json({ message: "Error To Deploy Application", error });
}
if (emptyApps) {
res.status(200).json({ message: "No apps to deploy" });
return;
}
res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) {
res.status(400).json({ message: "Error To Deploy Application", error });
}
} else if (req.headers["x-github-event"] === "pull_request") {
const prId = githubBody?.pull_request?.id;
if (githubBody?.action === "closed") {
const previewDeploymentResult =
await findPreviewDeploymentsByPullRequestId(prId);
if (previewDeploymentResult.length > 0) {
for (const previewDeployment of previewDeploymentResult) {
try {
await removePreviewDeployment(
previewDeployment.previewDeploymentId,
);
} catch (error) {
console.log(error);
}
}
}
res.status(200).json({ message: "Preview Deployment Closed" });
return;
}
// opened or synchronize or reopened
const repository = githubBody?.repository?.name;
const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref;
const owner = githubBody?.repository?.owner?.login;
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.repository, repository),
eq(applications.branch, branch),
eq(applications.isPreviewDeploymentsActive, true),
eq(applications.owner, owner),
),
with: {
previewDeployments: true,
},
});
const prBranch = githubBody?.pull_request?.head?.ref;
const prNumber = githubBody?.pull_request?.number;
const prTitle = githubBody?.pull_request?.title;
const prURL = githubBody?.pull_request?.html_url;
for (const app of apps) {
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult) {
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
pullRequestId: prId,
pullRequestNumber: prNumber,
pullRequestTitle: prTitle,
pullRequestURL: prURL,
});
previewDeploymentId = previewDeployment.previewDeploymentId;
}
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: "Preview Deployment",
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application-preview",
server: !!app.serverId,
previewDeploymentId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
return res.status(200).json({ message: "Apps Deployed" });
}
return res.status(400).json({ message: "No Actions matched" });
}

View File

@@ -13,7 +13,7 @@ export default async function handler(
const gitlab = await findGitlabById(gitlabId as string);
const response = await fetch("https://gitlab.com/oauth/token", {
const response = await fetch(`${gitlab.gitlabUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -39,6 +39,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { type ReactElement } from "react";
@@ -189,6 +190,9 @@ const Project = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>Project: {data?.name} | Dokploy</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<h1 className="text-xl font-bold lg:text-3xl">{data?.name}</h1>

View File

@@ -12,6 +12,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { DockerMonitoring } from "@/components/dashboard/monitoring/docker/show";
import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -40,9 +41,10 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
import React, { useState, useEffect, type ReactElement } from "react";
import superjson from "superjson";
type TabState =
@@ -51,7 +53,8 @@ type TabState =
| "advanced"
| "deployments"
| "domains"
| "monitoring";
| "monitoring"
| "preview-deployments";
const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
@@ -59,7 +62,14 @@ const Service = (
const { applicationId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const [tab, setTab] = useState<TabState>(activeTab);
useEffect(() => {
if (router.query.tab) {
setTab(router.query.tab as TabState);
}
}, [router.query.tab]);
const { data } = api.application.one.useQuery(
{ applicationId },
{
@@ -99,6 +109,11 @@ const Service = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Application: {data?.name} - {data?.project.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
@@ -183,16 +198,16 @@ const Service = (
defaultValue="general"
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
setTab(e as TabState);
const newPath = `/dashboard/project/${projectId}/services/application/${applicationId}?tab=${e}`;
router.push(newPath, undefined, { shallow: true });
router.push(newPath);
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
data?.serverId ? "md:grid-cols-6" : "md:grid-cols-7",
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",
data?.serverId ? "md:grid-cols-7" : "md:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
@@ -202,6 +217,9 @@ const Service = (
)}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="preview-deployments">
Preview Deployments
</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
@@ -244,6 +262,11 @@ const Service = (
<ShowDeployments applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="preview-deployments" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPreviewDeployments applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains applicationId={applicationId} />

View File

@@ -35,9 +35,10 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
import React, { useState, useEffect, type ReactElement } from "react";
import superjson from "superjson";
type TabState =
@@ -54,7 +55,14 @@ const Service = (
const { composeId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const [tab, setTab] = useState<TabState>(activeTab);
useEffect(() => {
if (router.query.tab) {
setTab(router.query.tab as TabState);
}
}, [router.query.tab]);
const { data } = api.compose.one.useQuery(
{ composeId },
{
@@ -94,6 +102,11 @@ const Service = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Compose: {data?.name} - {data?.project.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
@@ -177,9 +190,9 @@ const Service = (
defaultValue="general"
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
setTab(e as TabState);
const newPath = `/dashboard/project/${projectId}/services/compose/${composeId}?tab=${e}`;
router.push(newPath, undefined, { shallow: true });
router.push(newPath);
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">

View File

@@ -35,6 +35,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -82,6 +83,11 @@ const Mariadb = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Database: {data?.name} - {data?.project.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

View File

@@ -35,6 +35,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -83,6 +84,11 @@ const Mongo = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Database: {data?.name} - {data?.project.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

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