Compare commits

..

290 Commits

Author SHA1 Message Date
Mauricio Siu
839e1c0f9f Merge pull request #961 from Dokploy/canary
v0.15.1
2024-12-21 15:42:19 -06:00
Mauricio Siu
54dd531a26 Merge branch 'main' into canary 2024-12-21 15:36:20 -06:00
Mauricio Siu
7ebf5ad0f9 chore: bump version 2024-12-21 15:28:27 -06:00
Mauricio Siu
b85163d935 refactor: add conditional value 2024-12-21 15:10:00 -06:00
Mauricio Siu
a953e59327 Merge pull request #960 from Dokploy/fix/add-latest-cases
refactor: update digest
2024-12-21 15:00:51 -06:00
Mauricio Siu
b2661e4533 refactor: update 2024-12-21 14:58:58 -06:00
Mauricio Siu
883459624e refactor: update digest 2024-12-21 14:54:14 -06:00
Mauricio Siu
6e2b2d564b refactor: add missing additional ports 2024-12-21 14:11:06 -06:00
Mauricio Siu
065963857c Merge pull request #958 from Dokploy/fix/update-fetch
Fix/update fetch
2024-12-21 13:50:13 -06:00
Mauricio Siu
a0c9df4bd4 refactor: add 7 minutes interval 2024-12-21 13:35:17 -06:00
Mauricio Siu
68c8c70260 refactor: iterate pages 2024-12-21 13:34:50 -06:00
Mauricio Siu
a926f28d30 Merge pull request #930 from lost-end-found/additional-ports-management
feat(ports): implement additional ports management
2024-12-21 13:30:32 -06:00
Mauricio Siu
59c0636fb0 refactor: add is cloud to fn 2024-12-21 13:19:30 -06:00
Mauricio Siu
ae159c5678 refactor: add tag to web server update 2024-12-21 13:13:38 -06:00
Mauricio Siu
e42e9bec17 refactor: use schema inline 2024-12-21 12:48:32 -06:00
Mauricio Siu
978324e2bf Merge pull request #957 from drudge/check-for-updates
New Updates UI
2024-12-21 12:47:24 -06:00
Nicholas Penree
8f05f06259 Merge remote-tracking branch 'dokploy/canary' into check-for-updates 2024-12-21 13:47:01 -05:00
Mauricio Siu
392be2cfa2 Merge pull request #951 from szwabodev/checkUpdatesTweaks
feat: automatic check for updates
2024-12-21 12:45:39 -06:00
Mauricio Siu
18e89df9a5 Update apps/dokploy/components/layouts/navbar.tsx 2024-12-21 12:45:14 -06:00
UndefinedPony
4d2a9f8aa7 refactor: use dynamic tag for comparing latest tag digest 2024-12-21 13:35:39 -05:00
Nicholas Penree
d08530d451 feat(updates): clean up light mode 2024-12-21 13:02:58 -05:00
UndefinedPony
6c9b12cee9 refactor: use dynamic tag for comparing latest tag digest 2024-12-21 18:33:22 +01:00
Nicholas Penree
a8ff6c7b3f feat(updates): new update UI 2024-12-21 11:24:19 -05:00
UndefinedPony
8699e024ee refactor: add try catch, add default update data 2024-12-21 10:05:31 +01:00
Mauricio Siu
73782ffd26 Merge pull request #954 from Dokploy/915-daily-docker-cleanup-seems-to-be-doing-nothing
fix: add missing notifications in cron jobs
2024-12-21 02:51:52 -06:00
Mauricio Siu
7a8bb8f71d fix: add missing notifications in cron jobs 2024-12-21 02:45:58 -06:00
UndefinedPony
18eae9f7d7 refactor: use service image sha instead of image itself for checking updates 2024-12-21 09:04:25 +01:00
Mauricio Siu
1aae523a0b refactor: add missing verifyToken 2024-12-21 01:53:39 -06:00
UndefinedPony
f40e802331 fix: pull latest release in case of no image when checking update 2024-12-21 08:47:21 +01:00
Mauricio Siu
d979aa17c2 Merge pull request #948 from 190km/notifications-style
style(notifications): better notification item style
2024-12-20 23:34:57 -06:00
Mauricio Siu
e2d20fb0e3 Merge pull request #929 from drudge/certificate-details
feat(certs): show expiration and chain details
2024-12-20 23:23:33 -06:00
Mauricio Siu
62f59c1f9a Merge pull request #941 from mezotv/bump-plausible
feat(plausible): bump to 2.1.4
2024-12-20 23:17:22 -06:00
Mauricio Siu
93e1071057 Merge pull request #949 from 190km/fix-edit-password
fix(settings/profile): edit profile password fixed
2024-12-20 23:17:07 -06:00
Mauricio Siu
788771c5eb refactor: add password in validation 2024-12-20 23:16:38 -06:00
UndefinedPony
ab9aa56c48 refactor: disable automatic updates for cloud version 2024-12-20 18:57:28 +01:00
UndefinedPony
4565b3d7a2 refactor: add latestVersion information to update data 2024-12-20 18:26:54 +01:00
UndefinedPony
c8514e3a1b refactor: remove unused async 2024-12-20 17:32:10 +01:00
UndefinedPony
a06dd17aa1 feat(navbar): add automatic update checking interval, add update available button 2024-12-20 17:30:14 +01:00
UndefinedPony
256534570b refactor: add image tag helper, refactor update check logic, remove try/catch 2024-12-20 17:29:01 +01:00
UndefinedPony
2804748118 refactor: rename action, move pull to updateServer 2024-12-20 17:27:51 +01:00
UndefinedPony
e6bc40e7fe refactor: adapt to navbar version, move confirm action, add reload info 2024-12-20 17:27:14 +01:00
UndefinedPony
196603126b refactor: move check updates function, use new api 2024-12-20 17:26:31 +01:00
UndefinedPony
a5cd8f18cd feat: show auto check update toggle 2024-12-20 17:23:02 +01:00
UndefinedPony
b842887bc3 feat: add toggle for auto updates checking 2024-12-20 16:43:05 +01:00
UndefinedPony
dd64b06340 style: format with biome 2024-12-20 14:09:05 +01:00
UndefinedPony
d9a1976cc0 fix: check updates message fixes 2024-12-20 14:01:55 +01:00
190km
fdfa927532 feat(settings/profile): reset password form after validating password change 2024-12-20 01:00:16 +01:00
190km
bf2551b0f6 fix(settings/profile): fixed password changing 2024-12-20 00:54:32 +01:00
usopp
ed8be62ff3 chore: lint 2024-12-20 00:24:44 +01:00
usopp
77336a21f9 chore: lint 2024-12-20 00:21:51 +01:00
usopp
e05d01788f style(notifications): better notification item style 2024-12-20 00:11:48 +01:00
Dominik Koch
651e81ce6d feat(plausible): bump to 2.1.4 2024-12-19 11:22:58 +01:00
Mauricio Siu
fac29b70a5 Merge pull request #937 from drudge/fix-custom-registry
fix(docker): fix for custom registry login
2024-12-19 02:14:34 -06:00
Mauricio Siu
95eaab43df Merge pull request #936 from drudge/fix-light-term
fix(term): fix light mode foreground color
2024-12-19 02:14:26 -06:00
Mauricio Siu
abdef13b93 refactor: set current color 2024-12-19 02:14:06 -06:00
Mauricio Siu
65f397e1b1 Merge pull request #939 from 190km/2fa-typo-login
fix(2fa-login): typo - Setup -> Login
2024-12-19 02:08:26 -06:00
Mauricio Siu
1ae96297e8 refactor: update lint 2024-12-19 02:07:08 -06:00
Mauricio Siu
c51b502116 refactor: add path join to prevent concatenate double slash and update the getImageName 2024-12-19 02:05:30 -06:00
Mauricio Siu
5a42b78098 Merge pull request #940 from drudge/wrap-secrets
fix(preview-deployments): wrap long envs
2024-12-19 01:19:01 -06:00
Nicholas Penree
b39c0ef915 fix(preview-deployments): wrap long envs 2024-12-18 23:00:50 -05:00
Nicholas Penree
844d582147 fix(docker): fix for custom registry login 2024-12-18 21:58:22 -05:00
190km
0b51088489 fix: typo - Setup -> Login 2024-12-19 02:10:07 +01:00
Nicholas Penree
1dece58cff fix(term): fix light mode foreground color
closes #907
2024-12-18 13:56:09 -05:00
Larry Ioannidis
d22330f983 feat(ports): implement additional ports management 2024-12-18 09:39:20 +00:00
Mauricio Siu
852895c382 Merge pull request #912 from drudge/new-ansi-logs
feat(logs): support ansi codes
2024-12-17 23:55:12 -06:00
Mauricio Siu
20d5913820 Merge pull request #910 from drudge/canary
feat(cluster): use code editor for node config
2024-12-17 23:52:25 -06:00
Mauricio Siu
f1b4a73158 Merge pull request #928 from drudge/discord-notif
feat(discord): remove dots
2024-12-17 23:51:01 -06:00
Mauricio Siu
3830f6c4ee Merge pull request #925 from mohabgabber/canary
Added onedev docker compose
2024-12-17 23:50:33 -06:00
Mauricio Siu
5c8eda2405 Update apps/dokploy/templates/onedev/docker-compose.yml 2024-12-17 23:47:48 -06:00
Mauricio Siu
6bf85bcfa3 Merge branch 'canary' into new-ansi-logs 2024-12-17 23:43:18 -06:00
Mauricio Siu
bc03e718bf Merge pull request #911 from 190km/mutlti-select-fiter-logs
feat(logs): multi select fiter logs & hide/show timestamp
2024-12-17 23:42:50 -06:00
Nicholas Penree
a941efb1ff feat(certs): show expiration and chain details 2024-12-17 23:17:29 -05:00
Nicholas Penree
fe2de6b899 feat(discord): remove dots 2024-12-17 22:03:58 -05:00
usopp
b3313cf975 style: better white style 2024-12-17 19:16:40 +01:00
Mohab Gabber
4e31d8ac02 Added onedev to templates.ts and onedev's icon 2024-12-17 18:51:38 +02:00
Mohab Gabber
536507377d Added onedev docker compose 2024-12-17 17:23:26 +02:00
Nicholas Penree
6db9c99080 feat(logs): add number of lines filter 2024-12-16 20:12:05 -05:00
Nicholas Penree
7e8953ff44 chore: lint 2024-12-16 17:18:11 -05:00
Nicholas Penree
81c85ce155 fix: don't trigger if already selected 2024-12-16 17:12:18 -05:00
Nicholas Penree
bd16e03602 chore: lint 2024-12-16 17:07:31 -05:00
190km
87a5ce2053 fix: timestamp width 2024-12-16 22:55:36 +01:00
Nicholas Penree
ca4820940e feat(logs): filter improvements 2024-12-16 16:55:13 -05:00
190km
71fe6de9cb feat(logs): added show/hide timestamp option 2024-12-16 21:27:32 +01:00
Nicholas Penree
9ff4968e61 feat(logs): support ansi codes 2024-12-16 14:59:35 -05:00
190km
2312ae1c12 fix: fixed lint 2024-12-16 19:56:47 +01:00
190km
b03011a94f feat(logs): replaced the log type component with the new 2024-12-16 19:50:13 +01:00
190km
7577e40b25 feat(logs): added filter log type component 2024-12-16 19:49:09 +01:00
Nicholas Penree
75e34285ef feat(cluster): use code editor for node config 2024-12-16 11:51:17 -05:00
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
vishalkadam47
49559ebee6 fix: update GPU setup command to use sudo and add error handling 2024-12-08 06:27:34 +05:30
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
572579af91 Merge pull request #785 from Dokploy/canary
v0.13.1
2024-11-28 23:44:33 -06:00
Mauricio Siu
b296b6bbf0 Merge pull request #772 from Dokploy/canary
v0.13.0
2024-11-27 23:28:40 -06:00
Mauricio Siu
5c8721406a Merge pull request #720 from Dokploy/canary
v0.12.0
2024-11-17 22:07:33 -06:00
Mauricio Siu
be934065d9 Merge pull request #702 from Dokploy/canary
v0.11.2
2024-11-13 23:48:47 -06:00
Mauricio Siu
8162dcfb71 Merge pull request #697 from limichange/patch-1
Update plausible docker-compose.yml
2024-11-13 23:34:57 -06:00
limichange
46f7d43595 Update templates.ts 2024-11-14 09:37:20 +08:00
limichange
59bb59ee24 Update docker-compose.yml 2024-11-13 16:30:11 +08:00
Mauricio Siu
0e433a3d36 Merge pull request #689 from Dokploy/canary
v0.11.1
2024-11-12 01:04:36 -06:00
Mauricio Siu
819de5a32e Merge pull request #677 from Dokploy/canary
v0.11.0
2024-11-10 19:28:28 -06:00
Mauricio Siu
5cd624c7ea Merge pull request #647 from Dokploy/canary
v0.10.10
2024-11-04 10:32:20 -06:00
Mauricio Siu
89c7e96df0 Merge pull request #643 from Dokploy/canary
v0.10.9
2024-11-02 17:18:34 -06:00
Mauricio Siu
0e136ffb8f Merge pull request #632 from Dokploy/canary
v10.7.0
2024-10-30 23:10:48 -06:00
Mauricio Siu
6b4e52fb37 Merge pull request #631 from Dokploy/canary
v0.10.6
2024-10-30 23:00:45 -06:00
Mauricio Siu
db9136d981 Merge pull request #629 from Dokploy/canary
v0.10.5
2024-10-30 22:01:04 -06:00
Mauricio Siu
a93f18eb4a Merge pull request #599 from Dokploy/canary
v0.10.4
2024-10-25 21:35:15 -06:00
Mauricio Siu
467acc4d4d Merge pull request #586 from Dokploy/canary
v0.10.3
2024-10-24 01:21:17 -06:00
Mauricio Siu
01e5cf0852 Merge pull request #580 from Dokploy/canary
v0.10.2
2024-10-22 21:17:38 -06:00
Mauricio Siu
df9fad088f Merge pull request #567 from Dokploy/canary
v0.10.1
2024-10-18 23:12:12 -06:00
Mauricio Siu
2644b638d1 Merge pull request #553 from Dokploy/canary
v0.10.0
2024-10-13 16:30:00 -06:00
Mauricio Siu
acd722678e Merge pull request #521 from Dokploy/canary
v0.9.4
2024-10-03 02:12:03 -06:00
Mauricio Siu
727e50648e Merge pull request #501 from Dokploy/canary
v0.9.3
2024-09-29 16:30:43 -06:00
Mauricio Siu
349bc89851 Merge pull request #500 from Dokploy/canary
v0.9.2
2024-09-29 14:16:56 -06:00
Mauricio Siu
9f6f872536 Merge pull request #495 from Dokploy/canary
v0.9.1
2024-09-28 12:00:04 -06:00
Mauricio Siu
e378d89477 Merge pull request #475 from Dokploy/canary
v0.9.0
2024-09-22 19:38:12 -06:00
Mauricio Siu
63e7eacae9 chore(version): bump version 2024-09-19 16:37:00 -06:00
Mauricio Siu
f4ab588516 Merge pull request #466 from Dokploy/canary
v0.8.3
2024-09-19 16:01:27 -06:00
Mauricio Siu
4d8a0ba58f Merge pull request #457 from Dokploy/canary
v0.8.2
2024-09-16 15:57:20 -06:00
Mauricio Siu
e88cd11041 Merge pull request #427 from Dokploy/canary
v0.8.1
2024-09-07 13:25:36 -06:00
Mauricio Siu
5f174a883b Merge pull request #424 from Dokploy/canary
v0.8.0
2024-09-07 00:55:08 -06:00
Mauricio Siu
536a6ba2ff Merge pull request #397 from Dokploy/canary
v0.7.3
2024-08-30 00:27:59 -06:00
Mauricio Siu
213fa08210 Merge pull request #382 from Dokploy/canary
v0.7.2
2024-08-26 15:52:49 -06:00
Mauricio Siu
d5c6a601d8 Merge pull request #367 from Dokploy/canary
v0.7.1
2024-08-19 16:03:39 -06:00
Mauricio Siu
452793c8e5 Merge pull request #359 from Dokploy/canary
v0.7.0
2024-08-18 10:26:52 -06:00
Mauricio Siu
385fbf4af5 Merge pull request #355 from Dokploy/canary
v0.6.3
2024-08-16 22:26:35 -06:00
Mauricio Siu
3590f3bed2 Merge pull request #332 from Dokploy/canary
v0.6.2
2024-08-07 21:48:49 -06:00
Mauricio Siu
9b2fcaea31 Merge pull request #317 from Dokploy/canary
v0.6.1
2024-08-03 15:46:05 -06:00
Mauricio Siu
5abcc82215 Merge pull request #312 from Dokploy/canary
v0.6.0
2024-08-02 10:47:43 -06:00
Mauricio Siu
ee855452e3 Merge pull request #303 from Dokploy/canary
chore: add slash to version
2024-08-01 02:06:43 -06:00
Mauricio Siu
d000b526d3 Merge pull request #302 from Dokploy/canary
v0.5.1
2024-08-01 01:58:15 -06:00
Mauricio Siu
9bf88b90c3 Merge pull request #280 from Dokploy/canary
v0.5.0
2024-07-27 15:20:43 -06:00
Mauricio Siu
b1a48d4636 refactor: update job 2024-07-22 03:51:07 -06:00
Mauricio Siu
c34c4b244e Merge pull request #251 from Dokploy/canary
v0.4.0
2024-07-22 03:38:47 -06:00
Mauricio Siu
bb59a0cd3f Merge pull request #230 from Dokploy/canary
v0.3.3
2024-07-18 00:11:10 -06:00
Mauricio Siu
44e6a117dd Merge pull request #208 from Dokploy/canary
v0.3.2
2024-07-11 23:21:32 -06:00
Mauricio Siu
bfdc73f8d1 Merge pull request #197 from Dokploy/canary
v0.3.1
2024-07-06 12:01:07 -06:00
Mauricio Siu
64ada7020a Merge pull request #185 from Dokploy/canary
v0.3.0
2024-07-01 00:01:16 -06:00
Mauricio Siu
4706adc0c0 Merge pull request #174 from Dokploy/canary
v0.2.5
2024-06-29 13:29:39 -06:00
Mauricio Siu
e01d92d1d9 Merge pull request #161 from Dokploy/canary
v0.2.4
2024-06-23 19:40:45 -06:00
Mauricio Siu
fe22890311 Merge pull request #156 from Dokploy/canary
v0.2.3
2024-06-21 11:50:40 -06:00
Mauricio Siu
2b7c7632f4 Merge pull request #136 from Dokploy/canary
v0.2.2
2024-06-08 22:06:39 -06:00
Mauricio Siu
1b7244e841 Merge pull request #127 from Dokploy/canary
v0.2.1
2024-06-07 02:52:03 -06:00
168 changed files with 15632 additions and 1804 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@@ -48,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

@@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => {
</span>
</div>
)}
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
<FormField
control={form.control}

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-[#fafafa] 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

@@ -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

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

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-[#fafafa] 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

@@ -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

@@ -1,161 +0,0 @@
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,
FormField,
FormItem,
FormLabel,
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 { ImportIcon, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const updateComposeSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type UpdateCompose = z.infer<typeof updateComposeSchema>;
interface Props {
composeId: string;
}
export const ImportTemplate = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.compose.update.useMutation();
const { data } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const form = useForm<UpdateCompose>({
defaultValues: {
description: data?.description ?? "",
name: data?.name ?? "",
},
resolver: zodResolver(updateComposeSchema),
});
useEffect(() => {
if (data) {
form.reset({
description: data.description ?? "",
name: data.name,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateCompose) => {
await mutateAsync({
name: formData.name,
composeId: composeId,
description: formData.description || "",
})
.then(() => {
toast.success("Compose updated succesfully");
utils.compose.one.invalidate({
composeId: composeId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Compose");
})
.finally(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<ImportIcon className="size-5 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Import Template</DialogTitle>
<DialogDescription>Import external template</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-compose"
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description about your project..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-compose"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

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

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,114 +1,290 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Terminal } from "@xterm/xterm";
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 { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter";
import { TerminalLine } from "./terminal-line";
import { type LogLine, getLogType, parseLogs } from "./utils";
interface Props {
id: string;
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
export const priorities = [
{
label: "Info",
value: "info",
},
{
label: "Success",
value: "success",
},
{
label: "Warning",
value: "warning",
},
{
label: "Debug",
value: "debug",
},
{
label: "Error",
value: "error",
},
];
export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId: serverId ?? undefined,
},
{
enabled: !!containerId,
},
);
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>("");
const [showTimestamp, setShowTimestamp] = React.useState(true);
const [since, setSince] = React.useState<TimeFilter>("all");
const [typeFilter, setTypeFilter] = React.useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = React.useState(false);
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);
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value || "");
};
const handleLines = (lines: number) => {
setRawLogs("");
setFilteredLogs([]);
setLines(lines);
};
const handleSince = (value: TimeFilter) => {
setRawLogs("");
setFilteredLogs([]);
setSince(value);
};
useEffect(() => {
// if (containerId === "select-a-container") {
// return;
// }
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
}
if (!containerId) return;
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',
convertEol: true,
theme: {
cursor: "transparent",
background: "rgba(0, 0, 0, 0)",
},
});
let isCurrentConnection = true;
let noDataTimeout: NodeJS.Timeout;
setIsLoading(true);
setRawLogs("");
setFilteredLogs([]);
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new globalThis.URLSearchParams({
containerId,
tail: lines.toString(),
since,
search,
});
const wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`;
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);
wsRef.current = ws;
const fitAddon = new FitAddon();
termi.loadAddon(fitAddon);
// @ts-ignore
termi.open(container);
fitAddon.fit();
termi.focus();
setTerm(termi);
ws.onerror = (error) => {
console.error("WebSocket error: ", error);
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) => {
termi.write(e.data);
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) => {
console.log(e.reason);
termi.write(`Connection closed!\nReason: ${e.reason}\n`);
wsRef.current = null;
if (!isCurrentConnection) return;
console.log("WebSocket closed:", e.reason);
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};
return () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
isCurrentConnection = false;
if (noDataTimeout) clearTimeout(noDataTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.close();
wsRef.current = null;
}
};
}, [lines, containerId]);
}, [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;
if (typeFilter.length === 0) {
return true;
}
return typeFilter.includes(logType);
});
};
useEffect(() => {
term?.clear();
}, [lines, term]);
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="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>
<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">
<LineCountFilter value={lines} onValueChange={handleLines} />
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div id={id} />
<SinceLogsFilter
value={since}
onValueChange={handleSince}
showTimestamp={showTimestamp}
onTimestampChange={setShowTimestamp}
/>
<StatusLogsFilter
value={typeFilter}
setValue={setTypeFilter}
title="Log type"
options={priorities}
/>
<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 sm:w-auto w-full"
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-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
<TerminalLine
key={index}
log={filteredLog}
searchTerm={search}
noTimestamp={!showTimestamp}
/>
))
) : 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

@@ -0,0 +1,173 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { debounce } from "lodash";
import { CheckIcon, Hash } from "lucide-react";
import React, { useCallback, useRef } from "react";
const lineCountOptions = [
{ label: "100 lines", value: 100 },
{ label: "300 lines", value: 300 },
{ label: "500 lines", value: 500 },
{ label: "1000 lines", value: 1000 },
{ label: "5000 lines", value: 5000 },
] as const;
interface LineCountFilterProps {
value: number;
onValueChange: (value: number) => void;
title?: string;
}
export function LineCountFilter({
value,
onValueChange,
title = "Limit to",
}: LineCountFilterProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const pendingValueRef = useRef<number | null>(null);
const isPresetValue = lineCountOptions.some(
(option) => option.value === value,
);
const debouncedValueChange = useCallback(
debounce((numValue: number) => {
if (numValue > 0 && numValue !== value) {
onValueChange(numValue);
pendingValueRef.current = null;
}
}, 500),
[onValueChange, value],
);
const handleInputChange = (input: string) => {
setInputValue(input);
// Extract numbers from input and convert
const numValue = Number.parseInt(input.replace(/[^0-9]/g, ""));
if (!Number.isNaN(numValue)) {
pendingValueRef.current = numValue;
debouncedValueChange(numValue);
}
};
const handleSelect = (selectedValue: string) => {
const preset = lineCountOptions.find((opt) => opt.label === selectedValue);
if (preset) {
if (preset.value !== value) {
onValueChange(preset.value);
}
setInputValue("");
setOpen(false);
return;
}
const numValue = Number.parseInt(selectedValue);
if (
!Number.isNaN(numValue) &&
numValue > 0 &&
numValue !== value &&
numValue !== pendingValueRef.current
) {
onValueChange(numValue);
setInputValue("");
setOpen(false);
}
};
React.useEffect(() => {
return () => {
debouncedValueChange.cancel();
};
}, [debouncedValueChange]);
const displayValue = isPresetValue
? lineCountOptions.find((option) => option.value === value)?.label
: `${value} lines`;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
>
{title}
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="space-x-1 flex">
<Badge variant="blank" className="rounded-sm px-1 font-normal">
{displayValue}
</Badge>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<CommandPrimitive className="overflow-hidden rounded-md border border-none bg-popover text-popover-foreground">
<div className="flex items-center border-b px-3">
<Hash className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
placeholder="Number of lines"
value={inputValue}
onValueChange={handleInputChange}
className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const numValue = Number.parseInt(
inputValue.replace(/[^0-9]/g, ""),
);
if (
!Number.isNaN(numValue) &&
numValue > 0 &&
numValue !== value &&
numValue !== pendingValueRef.current
) {
handleSelect(inputValue);
}
}
}}
/>
</div>
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto overflow-x-hidden">
<CommandPrimitive.Group className="px-2 py-1.5">
{lineCountOptions.map((option) => {
const isSelected = value === option.value;
return (
<CommandPrimitive.Item
key={option.value}
onSelect={() => handleSelect(option.label)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary mr-2",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
<span>{option.label}</span>
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.Group>
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
);
}
export default LineCountFilter;

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,125 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
import React from "react";
export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
const timeRanges: Array<{ label: string; value: TimeFilter }> = [
{
label: "All time",
value: "all",
},
{
label: "Last hour",
value: "1h",
},
{
label: "Last 6 hours",
value: "6h",
},
{
label: "Last 24 hours",
value: "24h",
},
{
label: "Last 7 days",
value: "168h",
},
{
label: "Last 30 days",
value: "720h",
},
] as const;
interface SinceLogsFilterProps {
value: TimeFilter;
onValueChange: (value: TimeFilter) => void;
showTimestamp: boolean;
onTimestampChange: (show: boolean) => void;
title?: string;
}
export function SinceLogsFilter({
value,
onValueChange,
showTimestamp,
onTimestampChange,
title = "Time range",
}: SinceLogsFilterProps) {
const selectedLabel =
timeRanges.find((range) => range.value === value)?.label ??
"Select time range";
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
>
{title}
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="space-x-1 flex">
<Badge variant="blank" className="rounded-sm px-1 font-normal">
{selectedLabel}
</Badge>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
{timeRanges.map((range) => {
const isSelected = value === range.value;
return (
<CommandItem
key={range.value}
onSelect={() => {
if (!isSelected) {
onValueChange(range.value);
}
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
<span className="text-sm">{range.label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
<Separator className="my-2" />
<div className="p-2 flex items-center justify-between">
<span className="text-sm">Show timestamps</span>
<Switch checked={showTimestamp} onCheckedChange={onTimestampChange} />
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,170 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
import type React from "react";
interface StatusLogsFilterProps {
value?: string[];
setValue?: (value: string[]) => void;
title?: string;
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
}
export function StatusLogsFilter({
value = [],
setValue,
title,
options,
}: StatusLogsFilterProps) {
const selectedValues = new Set(value as string[]);
const allSelected = selectedValues.size === 0;
const getSelectedBadges = () => {
if (allSelected) {
return (
<Badge variant="blank" className="rounded-sm px-1 font-normal">
All
</Badge>
);
}
if (selectedValues.size >= 1) {
const selected = options.find((opt) => selectedValues.has(opt.value));
return (
<>
<Badge
variant={
selected?.value === "success"
? "green"
: selected?.value === "error"
? "red"
: selected?.value === "warning"
? "orange"
: selected?.value === "info"
? "blue"
: selected?.value === "debug"
? "yellow"
: "blank"
}
className="rounded-sm px-1 font-normal"
>
{selected?.label}
</Badge>
{selectedValues.size > 1 && (
<Badge variant="blank" className="rounded-sm px-1 font-normal">
+{selectedValues.size - 1}
</Badge>
)}
</>
);
}
return null;
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
>
{title}
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="space-x-1 flex">{getSelectedBadges()}</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
setValue?.([]); // Empty array means "All"
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
allSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
<Badge variant="blank">All</Badge>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
const newValues = new Set(selectedValues);
if (isSelected) {
newValues.delete(option.value);
} else {
newValues.add(option.value);
}
setValue?.(Array.from(newValues));
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<Badge
variant={
option.value === "success"
? "green"
: option.value === "error"
? "red"
: option.value === "warning"
? "orange"
: option.value === "info"
? "blue"
: option.value === "debug"
? "yellow"
: "blank"
}
>
{option.label}
</Badge>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,131 @@
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, parseAnsi } 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) {
const segments = parseAnsi(text);
return segments.map((segment, index) => (
<span key={index} className={segment.className || undefined}>
{segment.text}
</span>
));
}
// For search, we need to handle both ANSI and search highlighting
const segments = parseAnsi(text);
return segments.map((segment, index) => {
const parts = segment.text.split(
new RegExp(`(${escapeRegExp(term)})`, "gi"),
);
return (
<span key={index} className={segment.className || undefined}>
{parts.map((part, partIndex) =>
part.toLowerCase() === term.toLowerCase() ? (
<span
key={partIndex}
className="bg-yellow-200 dark:bg-yellow-900"
>
{part}
</span>
) : (
part
),
)}
</span>
);
});
};
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">
{highlightMessage(message, searchTerm || "")}
</span>
</div>
);
}

View File

@@ -0,0 +1,246 @@
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;
}
interface AnsiSegment {
text: string;
className: string;
}
const ansiToTailwind: Record<number, string> = {
// Reset
0: "",
// Regular colors
30: "text-black dark:text-gray-900",
31: "text-red-600 dark:text-red-500",
32: "text-green-600 dark:text-green-500",
33: "text-yellow-600 dark:text-yellow-500",
34: "text-blue-600 dark:text-blue-500",
35: "text-purple-600 dark:text-purple-500",
36: "text-cyan-600 dark:text-cyan-500",
37: "text-gray-600 dark:text-gray-400",
// Bright colors
90: "text-gray-500 dark:text-gray-600",
91: "text-red-500 dark:text-red-600",
92: "text-green-500 dark:text-green-600",
93: "text-yellow-500 dark:text-yellow-600",
94: "text-blue-500 dark:text-blue-600",
95: "text-purple-500 dark:text-purple-600",
96: "text-cyan-500 dark:text-cyan-600",
97: "text-white dark:text-gray-300",
// Background colors
40: "bg-black",
41: "bg-red-600",
42: "bg-green-600",
43: "bg-yellow-600",
44: "bg-blue-600",
45: "bg-purple-600",
46: "bg-cyan-600",
47: "bg-white",
// Formatting
1: "font-bold",
2: "opacity-75",
3: "italic",
4: "underline",
};
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;
};
export function parseAnsi(text: string) {
const segments: { text: string; className: string }[] = [];
let currentIndex = 0;
let currentClasses: string[] = [];
while (currentIndex < text.length) {
const escStart = text.indexOf("\x1b[", currentIndex);
// No more escape sequences found
if (escStart === -1) {
if (currentIndex < text.length) {
segments.push({
text: text.slice(currentIndex),
className: currentClasses.join(" "),
});
}
break;
}
// Add text before escape sequence
if (escStart > currentIndex) {
segments.push({
text: text.slice(currentIndex, escStart),
className: currentClasses.join(" "),
});
}
const escEnd = text.indexOf("m", escStart);
if (escEnd === -1) break;
// Handle multiple codes in one sequence (e.g., \x1b[1;31m)
const codesStr = text.slice(escStart + 2, escEnd);
const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10));
if (codes.includes(0)) {
// Reset all formatting
currentClasses = [];
} else {
// Add new classes for each code
for (const code of codes) {
const className = ansiToTailwind[code];
if (className && !currentClasses.includes(className)) {
currentClasses.push(className);
}
}
}
currentIndex = escEnd + 1;
}
return segments;
}

View File

@@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
interface Props {
id: string;
@@ -18,6 +19,7 @@ export const DockerTerminal: React.FC<Props> = ({
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme();
useEffect(() => {
const container = document.getElementById(id);
if (container) {
@@ -25,13 +27,12 @@ export const DockerTerminal: React.FC<Props> = ({
}
const term = new Terminal({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: "transparent",
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
},
});
const addonFit = new FitAddon();
@@ -45,6 +46,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 +68,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

@@ -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

@@ -6,13 +6,144 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ShieldCheck } from "lucide-react";
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
import { AddCertificate } from "./add-certificate";
import { DeleteCertificate } from "./delete-certificate";
export const ShowCertificates = () => {
const { data } = api.certificates.all.useQuery();
const extractExpirationDate = (certData: string): Date | null => {
try {
const match = certData.match(
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
);
if (!match?.[1]) return null;
const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
let dateFound = 0;
for (let i = 0; i < bytes.length - 2; i++) {
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
const dateType = bytes[i];
const dateLength = bytes[i + 1];
if (typeof dateLength === "undefined") continue;
if (dateFound === 0) {
dateFound++;
i += dateLength + 1;
continue;
}
let dateStr = "";
for (let j = 0; j < dateLength; j++) {
const charCode = bytes[i + 2 + j];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate:", error);
return null;
}
};
const getExpirationStatus = (certData: string) => {
const expirationDate = extractExpirationDate(certData);
if (!expirationDate)
return {
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
};
const now = new Date();
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysUntilExpiration < 0) {
return {
status: "expired" as const,
className: "text-red-500",
message: `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
}
if (daysUntilExpiration <= 30) {
return {
status: "warning" as const,
className: "text-yellow-500",
message: `Expires in ${daysUntilExpiration} days`,
};
}
return {
status: "valid" as const,
className: "text-muted-foreground",
message: `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
};
const getCertificateChainInfo = (certData: string) => {
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
return certCount > 1
? {
isChain: true,
count: certCount,
}
: {
isChain: false,
count: 1,
};
};
return (
<div className="h-full">
<Card className="bg-transparent h-full">
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4 h-full">
{data?.length === 0 ? (
{!data?.length ? (
<div className="flex flex-col items-center gap-3">
<ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
@@ -35,21 +166,53 @@ export const ShowCertificates = () => {
) : (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{data?.map((destination, index) => (
<div
key={destination.certificateId}
className="flex items-center justify-between border p-4 rounded-lg"
>
<span className="text-sm text-muted-foreground">
{index + 1}. {destination.name}
</span>
<div className="flex flex-row gap-3">
<DeleteCertificate
certificateId={destination.certificateId}
/>
{data.map((certificate, index) => {
const expiration = getExpirationStatus(
certificate.certificateData,
);
const chainInfo = getCertificateChainInfo(
certificate.certificateData,
);
return (
<div
key={certificate.certificateId}
className="flex flex-col border p-4 rounded-lg space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{index + 1}. {certificate.name}
</span>
{chainInfo.isChain && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count})
</span>
</div>
)}
</div>
<DeleteCertificate
certificateId={certificate.certificateId}
/>
</div>
<div
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
>
{expiration.status !== "valid" && (
<AlertCircle className="size-3" />
)}
{expiration.message}
{certificate.autoRenew &&
expiration.status !== "valid" && (
<span className="text-xs text-emerald-500 ml-1">
(Auto-renewal enabled)
</span>
)}
</div>
</div>
</div>
))}
);
})}
</div>
<div>
<AddCertificate />

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import {
Dialog,
DialogContent,
@@ -33,7 +34,13 @@ export const ShowNodeData = ({ data }: Props) => {
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
<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

@@ -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

@@ -11,7 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner";
@@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-9 w-9 group hover:bg-red-500/10"
isLoading={isLoading}
>
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -40,48 +40,58 @@ export const ShowNotifications = () => {
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
{data?.map((notification, index) => (
<div
key={notification.notificationId}
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
>
<div className="flex flex-row gap-2 items-center w-full ">
{notification.notificationType === "slack" && (
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
)}
{notification.notificationType === "telegram" && (
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
)}
{notification.notificationType === "discord" && (
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
)}
{notification.notificationType === "email" && (
<Mail
size={29}
className="text-muted-foreground size-6 flex-shrink-0"
/>
)}
<span className="text-sm text-muted-foreground">
{notification.name}
</span>
</div>
<div className="flex flex-row gap-1 w-fit">
<UpdateNotification
notificationId={notification.notificationId}
/>
<DeleteNotification
notificationId={notification.notificationId}
/>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-4 justify-end w-full items-end">
<AddNotification />
<div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
{data?.map((notification, index) => (
<div
key={notification.notificationId}
className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800/50"
>
<div className="flex items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
<SlackIcon className="h-6 w-6 text-indigo-400" />
</div>
)}
{notification.notificationType === "telegram" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
<TelegramIcon className="h-6 w-6 text-indigo-400" />
</div>
)}
{notification.notificationType === "discord" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
<DiscordIcon className="h-6 w-6 text-indigo-400" />
</div>
)}
{notification.notificationType === "email" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
<Mail className="h-6 w-6 text-indigo-400" />
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-medium text-zinc-300">
{notification.name}
</span>
<span className="text-xs font-medium text-muted-foreground">
{notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification
</span>
</div>
</div>
<div className="flex items-center gap-2">
<UpdateNotification
notificationId={notification.notificationId}
/>
<DeleteNotification
notificationId={notification.notificationId}
/>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-4 justify-end w-full items-end">
<AddNotification />
</div>
</div>
)}
</CardContent>
</Card>

View File

@@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, PenBoxIcon } from "lucide-react";
import { Mail, Pen } from "lucide-react";
import { useEffect, useState } from "react";
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -218,8 +218,10 @@ export const UpdateNotification = ({ notificationId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
<Button variant="ghost"
size="icon"
className="h-9 w-9">
<Pen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">

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,10 +99,12 @@ export const ProfileForm = () => {
email: values.email.toLowerCase(),
password: values.password,
image: values.image,
currentPassword: values.currentPassword,
})
.then(async () => {
await refetch();
toast.success("Profile Updated");
form.reset();
})
.catch(() => {
toast.error("Error to Update the profile");
@@ -116,6 +123,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 +144,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

@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "next-i18next";
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
import { ShowModalLogs } from "../../web-server/show-modal-logs";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
interface Props {
serverId?: string;
@@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<span>Enter the terminal</span>
</DropdownMenuItem>
</DockerTerminalModal> */}
<ManageTraefikPorts serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

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,8 +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;
@@ -89,12 +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-4 w-[600px]">
<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
@@ -139,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"
>
@@ -198,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">
@@ -214,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">
@@ -303,6 +338,14 @@ export const SetupServer = ({ serverId }: Props) => {
<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

@@ -1,4 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -8,9 +9,8 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { StatusRow } from "./gpu-support";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { StatusRow } from "./gpu-support";
interface Props {
serverId: string;
@@ -66,7 +66,7 @@ export const ValidateServer = ({ serverId }: Props) => {
{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>
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
@@ -113,16 +113,31 @@ export const ValidateServer = ({ serverId }: Props) => {
}
/>
<StatusRow
label="Dokploy Network Installed"
isEnabled={data?.isDokployNetworkInstalled}
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Swarm Installed"
isEnabled={data?.isSwarmInstalled}
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>

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

@@ -0,0 +1,230 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { useTranslation } from "next-i18next";
import type React from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
/**
* Props for the ManageTraefikPorts component
* @interface Props
* @property {React.ReactNode} children - The trigger element that opens the ports management modal
* @property {string} [serverId] - Optional ID of the server whose ports are being managed
*/
interface Props {
children: React.ReactNode;
serverId?: string;
}
/**
* Represents a port mapping configuration for Traefik
* @interface AdditionalPort
* @property {number} targetPort - The internal port that the service is listening on
* @property {number} publishedPort - The external port that will be exposed
* @property {"ingress" | "host"} publishMode - The Docker Swarm publish mode:
* - "host": Publishes the port directly on the host
* - "ingress": Publishes the port through the Swarm routing mesh
*/
interface AdditionalPort {
targetPort: number;
publishedPort: number;
publishMode: "ingress" | "host";
}
/**
* ManageTraefikPorts is a component that provides a modal interface for managing
* additional port mappings for Traefik in a Docker Swarm environment.
*
* Features:
* - Add, remove, and edit port mappings
* - Configure target port, published port, and publish mode for each mapping
* - Persist port configurations through API calls
*
* @component
* @example
* ```tsx
* <ManageTraefikPorts serverId="server-123">
* <Button>Manage Ports</Button>
* </ManageTraefikPorts>
* ```
*/
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const [additionalPorts, setAdditionalPorts] = useState<AdditionalPort[]>([]);
const { data: currentPorts, refetch: refetchPorts } =
api.settings.getTraefikPorts.useQuery({
serverId,
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
useEffect(() => {
if (currentPorts) {
setAdditionalPorts(currentPorts);
}
}, [currentPorts]);
const handleAddPort = () => {
setAdditionalPorts([
...additionalPorts,
{ targetPort: 0, publishedPort: 0, publishMode: "host" },
]);
};
const handleUpdatePorts = async () => {
try {
await updatePorts({
serverId,
additionalPorts,
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (error) {
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
}
};
return (
<>
<div onClick={() => setOpen(true)}>{children}</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("settings.server.webServer.traefik.managePorts")}
</DialogTitle>
<DialogDescription>
{t("settings.server.webServer.traefik.managePortsDescription")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{additionalPorts.map((port, index) => (
<div
key={index}
className="grid grid-cols-[120px_120px_minmax(120px,1fr)_80px] gap-4 items-end"
>
<div className="space-y-2">
<Label htmlFor={`target-port-${index}`}>
{t("settings.server.webServer.traefik.targetPort")}
</Label>
<input
id={`target-port-${index}`}
type="number"
value={port.targetPort}
onChange={(e) => {
const newPorts = [...additionalPorts];
if (newPorts[index]) {
newPorts[index].targetPort = Number.parseInt(
e.target.value,
);
}
setAdditionalPorts(newPorts);
}}
className="w-full rounded border p-2"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`published-port-${index}`}>
{t("settings.server.webServer.traefik.publishedPort")}
</Label>
<input
id={`published-port-${index}`}
type="number"
value={port.publishedPort}
onChange={(e) => {
const newPorts = [...additionalPorts];
if (newPorts[index]) {
newPorts[index].publishedPort = Number.parseInt(
e.target.value,
);
}
setAdditionalPorts(newPorts);
}}
className="w-full rounded border p-2"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`publish-mode-${index}`}>
{t("settings.server.webServer.traefik.publishMode")}
</Label>
<Select
value={port.publishMode}
onValueChange={(value: "ingress" | "host") => {
const newPorts = [...additionalPorts];
if (newPorts[index]) {
newPorts[index].publishMode = value;
}
setAdditionalPorts(newPorts);
}}
>
<SelectTrigger
id={`publish-mode-${index}`}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="host">Host</SelectItem>
<SelectItem value="ingress">Ingress</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Button
onClick={() => {
const newPorts = additionalPorts.filter(
(_, i) => i !== index,
);
setAdditionalPorts(newPorts);
}}
variant="destructive"
size="sm"
>
Remove
</Button>
</div>
</div>
))}
<div className="mt-4 flex justify-between">
<Button onClick={handleAddPort} variant="outline" size="sm">
{t("settings.server.webServer.traefik.addPort")}
</Button>
<Button
onClick={handleUpdatePorts}
size="sm"
disabled={isLoading}
>
Save
</Button>
</div>
</div>
</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

@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
interface Props {
id: string;
@@ -12,7 +13,7 @@ interface Props {
export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const termRef = useRef(null);
const { resolvedTheme } = useTheme();
useEffect(() => {
const container = document.getElementById(id);
if (container) {
@@ -20,13 +21,12 @@ 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",
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
},
});
const addonFit = new FitAddon();
@@ -40,6 +40,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 +51,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

@@ -0,0 +1,28 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useState } from "react";
export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => {
const [enabled, setEnabled] = useState<boolean>(
localStorage.getItem("enableAutoCheckUpdates") === "true",
);
const handleToggle = (checked: boolean) => {
setEnabled(checked);
localStorage.setItem("enableAutoCheckUpdates", String(checked));
};
return (
<div className="flex items-center gap-4">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
id="autoCheckUpdatesToggle"
disabled={disabled}
/>
<Label className="text-primary" htmlFor="autoCheckUpdatesToggle">
Automatically check for new updates
</Label>
</div>
);
};

View File

@@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import {
Bug,
Download,
Info,
RefreshCcw,
Server,
ServerCrash,
Sparkles,
Stars,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
import { UpdateWebServer } from "./update-webserver";
export const UpdateServer = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>(
null,
);
const { mutateAsync: checkAndUpdateImage, isLoading } =
api.settings.checkAndUpdateImage.useMutation();
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const { mutateAsync: getUpdateData, isLoading } =
api.settings.getUpdateData.useMutation();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState("");
const handleCheckUpdates = async () => {
try {
const updateData = await getUpdateData();
const versionToUpdate = updateData.latestVersion || "";
setHasCheckedUpdate(true);
setIsUpdateAvailable(updateData.updateAvailable);
setLatestVersion(versionToUpdate);
if (updateData.updateAvailable) {
toast.success(versionToUpdate, {
description: "New version available!",
});
} else {
toast.info("No updates available");
}
} catch (error) {
console.error("Error checking for updates:", error);
setHasCheckedUpdate(true);
setIsUpdateAvailable(false);
toast.error(
"An error occurred while checking for updates, please try again.",
);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
<RefreshCcw className="h-4 w-4" />
<Button variant="secondary" className="gap-2">
<Sparkles className="h-4 w-4" />
Updates
</Button>
</DialogTrigger>
<DialogContent className="sm:m:max-w-lg ">
<DialogHeader>
<DialogTitle>Web Server Update</DialogTitle>
<DialogDescription>
Check new releases and update your dokploy
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-lg p-6">
<div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold">
Web Server Update
</DialogTitle>
{dokployVersion && (
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion} | {releaseTag}
</span>
</div>
)}
</div>
<div className="flex flex-col gap-4">
<span className="text-sm text-muted-foreground">
We suggest to update your dokploy to the latest version only if you:
</span>
<ul className="list-disc list-inside text-sm text-muted-foreground">
<li>Want to try the latest features</li>
<li>Some bug that is blocking to use some features</li>
</ul>
<AlertBlock type="info">
We recommend checking the latest version for any breaking changes
before updating. Go to{" "}
<Link
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-foreground"
>
Dokploy Releases
</Link>{" "}
to check the latest version.
</AlertBlock>
{/* Initial state */}
{!hasCheckedUpdate && (
<div className="mb-8">
<p className="text text-muted-foreground">
Check for new releases and update Dokploy.
<br />
<br />
We recommend checking for updates regularly to ensure you have the
latest features and security improvements.
</p>
</div>
)}
<div className="w-full flex flex-col gap-4">
{isUpdateAvailable === false && (
<div className="flex flex-col items-center gap-3">
<RefreshCcw className="size-6 self-center text-muted-foreground" />
<span className="text-sm text-muted-foreground">
You are using the latest version
{/* Update available state */}
{isUpdateAvailable && latestVersion && (
<div className="mb-8">
<div className="inline-flex items-center gap-2 rounded-lg px-3 py-2 border border-emerald-900 bg-emerald-900 dark:bg-emerald-900/40 mb-4 w-full">
<div className="flex items-center gap-1.5">
<span className="flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
<Download className="h-4 w-4 text-emerald-400" />
<span className="text font-medium text-emerald-400 ">
New version available:
</span>
</div>
)}
<span className="text font-semibold text-emerald-300">
{latestVersion}
</span>
</div>
<div className="space-y-4 text-muted-foreground">
<p className="text">
A new version of the server software is available. Consider
updating if you:
</p>
<ul className="space-y-3">
<li className="flex items-start gap-2">
<Stars className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Want to access the latest features and improvements
</span>
</li>
<li className="flex items-start gap-2">
<Bug className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Are experiencing issues that may be resolved in the new
version
</span>
</li>
</ul>
</div>
</div>
)}
{/* Up to date state */}
{hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-emerald-400/40">
<Sparkles className="h-8 w-8 text-emerald-400" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">
You are using the latest version
</h3>
<p className="text text-muted-foreground">
Your server is up to date with all the latest features and
security improvements.
</p>
</div>
</div>
</div>
)}
{hasCheckedUpdate && isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-[#5B9DFF]/40 text-foreground">
<RefreshCcw className="h-8 w-8 animate-spin" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Checking for updates...</h3>
<p className="text text-muted-foreground">
Please wait while we pull the latest version information from
Docker Hub.
</p>
</div>
</div>
</div>
)}
{isUpdateAvailable && (
<div className="rounded-lg bg-[#16254D] p-4 mb-8">
<div className="flex gap-2">
<Info className="h-5 w-5 flex-shrink-0 text-[#5B9DFF]" />
<div className="text-[#5B9DFF]">
We recommend reviewing the{" "}
<Link
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-white underline hover:text-zinc-200"
>
release notes
</Link>{" "}
for any breaking changes before updating.
</div>
</div>
</div>
)}
<div className="flex items-center justify-between pt-2">
<ToggleAutoCheckUpdates disabled={isLoading} />
</div>
<div className="space-y-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{isUpdateAvailable ? (
<UpdateWebServer />
) : (
<Button
className="w-full"
onClick={async () => {
await checkAndUpdateImage()
.then(async (e) => {
setIsUpdateAvailable(e);
})
.catch(() => {
setIsUpdateAvailable(false);
toast.error("Error to check updates");
});
toast.success("Check updates");
}}
isLoading={isLoading}
variant="secondary"
onClick={handleCheckUpdates}
disabled={isLoading}
>
Check Updates
{isLoading ? (
<>
<RefreshCcw className="h-4 w-4 animate-spin" />
Checking for updates
</>
) : (
<>
<RefreshCcw className="h-4 w-4" />
Check for updates
</>
)}
</Button>
)}
</div>
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
</Dialog>
);
};
export default UpdateServer;

View File

@@ -11,24 +11,53 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { HardDriveDownload } from "lucide-react";
import { toast } from "sonner";
export const UpdateWebServer = () => {
interface Props {
isNavbar?: boolean;
}
export const UpdateWebServer = ({ isNavbar }: Props) => {
const { mutateAsync: updateServer, isLoading } =
api.settings.updateServer.useMutation();
const buttonLabel = isNavbar ? "Update available" : "Update Server";
const handleConfirm = async () => {
try {
await updateServer();
toast.success(
"The server has been updated. The page will be reloaded to reflect the changes...",
);
setTimeout(() => {
// Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch (error) {
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
);
}
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="relative w-full"
variant="secondary"
variant={isNavbar ? "outline" : "secondary"}
isLoading={isLoading}
>
<span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
Update Server
{!isLoading && <HardDriveDownload className="h-4 w-4" />}
{!isLoading && (
<span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
)}
{isLoading ? "Updating..." : buttonLabel}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will update the web server to the
new version.
new version. The page will be reloaded once the update is finished.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
<AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

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

@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
import { HeartIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver";
import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const Navbar = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
const router = useRouter();
const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -29,6 +34,59 @@ export const Navbar = () => {
},
);
const { mutateAsync } = api.auth.logout.useMutation();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
useEffect(() => {
// Handling of automatic check for server updates
if (isCloud) {
return;
}
if (!localStorage.getItem("enableAutoCheckUpdates")) {
// Enable auto update checking by default if user didn't change it
localStorage.setItem("enableAutoCheckUpdates", "true");
}
const clearUpdatesInterval = () => {
if (checkUpdatesIntervalRef.current) {
clearInterval(checkUpdatesIntervalRef.current);
}
};
const checkUpdates = async () => {
try {
if (localStorage.getItem("enableAutoCheckUpdates") !== "true") {
return;
}
const { updateAvailable } = await getUpdateData();
if (updateAvailable) {
// Stop interval when update is available
clearUpdatesInterval();
setIsUpdateAvailable(true);
}
} catch (error) {
console.error("Error auto-checking for updates:", error);
}
};
checkUpdatesIntervalRef.current = setInterval(
checkUpdates,
AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000,
);
// Also check for updates on initial page load
checkUpdates();
return () => {
clearUpdatesInterval();
};
}, []);
return (
<nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl">
<header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16">
@@ -43,6 +101,11 @@ export const Navbar = () => {
</span>
</Link>
</div>
{isUpdateAvailable && (
<div>
<UpdateWebServer isNavbar />
</div>
)}
<Link
className={buttonVariants({
variant: "outline",

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-600/20 dark: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-600/20 dark:bg-yellow-500/15 dark:text-yellow-500 text-yellow-600 text-xs h-4 px-1 py-1 rounded-md",
orange:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-orange-600/20 dark:bg-orange-500/15 dark:text-orange-500 text-orange-600 text-xs h-4 px-1 py-1 rounded-md",
green:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-500 text-emerald-600 text-xs h-4 px-1 py-1 rounded-md",
blue: "border-transparent select-none items-center whitespace-nowrap font-medium bg-blue-600/20 dark:bg-blue-500/15 dark:text-blue-500 text-blue-600 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

@@ -62,6 +62,7 @@ export const Secrets = (props: Props) => {
}
language="properties"
disabled={isVisible}
lineWrapping
placeholder={props.placeholder}
className="h-96 font-mono"
{...field}

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 "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

View File

@@ -351,6 +351,20 @@
"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

@@ -11,6 +11,8 @@ export enum Languages {
Persian = "fa",
Korean = "ko",
Portuguese = "pt-br",
Italian = "it",
Japanese = "ja",
}
export type Language = keyof typeof Languages;

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.14.0",
"version": "v0.15.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -35,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,5 +1,6 @@
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";
@@ -33,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>
@@ -56,6 +57,7 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
</>
@@ -63,20 +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: Object.values(Languages),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages),
localeDetection: false,
},
),
fallbackLng: "en",
keySeparator: false,
}),
);

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

@@ -41,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 =
@@ -61,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 },
{
@@ -101,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">
@@ -185,9 +198,9 @@ 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">

View File

@@ -5,7 +5,6 @@ import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deploymen
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ImportTemplate } from "@/components/dashboard/compose/import-template";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowMonitoringCompose } from "@/components/dashboard/compose/monitoring/show";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
@@ -36,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 =
@@ -55,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 },
{
@@ -95,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">
@@ -178,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">
@@ -207,7 +219,6 @@ const Service = (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<div className="flex flex-row gap-2">
<ImportTemplate composeId={composeId} />
<UpdateCompose composeId={composeId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (

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">

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";
@@ -81,6 +82,11 @@ const MySql = (
<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";
@@ -82,6 +83,11 @@ const Postgresql = (
<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

@@ -34,6 +34,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";
@@ -81,6 +82,11 @@ const Redis = (
<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

@@ -21,7 +21,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Appearance">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -16,7 +16,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Billing">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -19,7 +19,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Certificates">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Nodes">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="S3 Destinations">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Git Providers">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Notifications">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -1,5 +1,6 @@
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { RemoveSelfAccount } from "@/components/dashboard/settings/profile/remove-self-account";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { appRouter } from "@/server/api/root";
@@ -21,10 +22,14 @@ const Page = () => {
enabled: !!data?.id && data?.rol === "user",
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<div className="flex flex-col gap-4 w-full">
<ProfileForm />
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
{isCloud && <RemoveSelfAccount />}
</div>
);
};
@@ -33,7 +38,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Profile">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Registry">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -23,7 +23,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Server">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Servers">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="SSH Keys">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Users">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -134,7 +134,7 @@ const Register = ({ isCloud }: Props) => {
</span>
</div>
)}
{data && (
{data?.type === "cloud" && (
<AlertBlock type="success" className="mx-4 my-2">
<span>
Registration succesfuly, Please check your inbox or spam

View File

@@ -18,6 +18,14 @@
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modify Env",
"settings.server.webServer.traefik.managePorts": "Additional Ports",
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
"settings.server.webServer.traefik.targetPort": "Target Port",
"settings.server.webServer.traefik.publishedPort": "Published Port",
"settings.server.webServer.traefik.addPort": "Add Port",
"settings.server.webServer.traefik.portsUpdated": "Ports updated successfully",
"settings.server.webServer.traefik.portsUpdateError": "Failed to update ports",
"settings.server.webServer.traefik.publishMode": "Publish Mode",
"settings.server.webServer.storage.label": "Space",
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",

View File

@@ -0,0 +1 @@
{}

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