Compare commits

...

361 Commits

Author SHA1 Message Date
Mauricio Siu
790894ab93 refactor: migrate admin API calls to user router 2025-02-20 23:02:02 -06:00
Mauricio Siu
5a1145996d feat: add backup code authentication for 2FA login 2025-02-20 01:50:01 -06:00
Mauricio Siu
a9e12c2b18 refactor: update organization context in API routers 2025-02-20 01:42:35 -06:00
Mauricio Siu
b73e4102dd feat: add organizations and members 2025-02-17 02:48:42 -06:00
Mauricio Siu
c7d47a6003 refactor: update database foreign key constraints and user management 2025-02-17 00:30:15 -06:00
Mauricio Siu
8c28223343 refactor: remove 2fa migration 2025-02-17 00:10:34 -06:00
Mauricio Siu
7abe060fcf feat: enhance two-factor authentication and auth client implementation 2025-02-17 00:07:36 -06:00
Mauricio Siu
0e8e92c715 refactor: add 2fa 2025-02-16 20:56:50 -06:00
Mauricio Siu
e1632cbdb3 refactor: update user and authentication schema with two-factor support 2025-02-16 15:32:57 -06:00
Mauricio Siu
90156da570 refactor: remove tables 2025-02-16 14:11:47 -06:00
Mauricio Siu
9856502ece refactor: remove old references 2025-02-16 13:55:27 -06:00
Mauricio Siu
a8d1471b16 refactor: update 2025-02-16 13:28:29 -06:00
Mauricio Siu
27736c7c97 refactor: update role and validation handling across multiple pages 2025-02-16 03:06:22 -06:00
Mauricio Siu
e7db0ccb70 refactor: update invitation 2025-02-16 02:57:49 -06:00
Mauricio Siu
4a1a14aeb4 refactor: update 2025-02-15 23:24:45 -06:00
Mauricio Siu
ed62b4e1a3 refactor: lint 2025-02-15 23:01:44 -06:00
Mauricio Siu
515d65d993 refactor: adjust queries 2025-02-15 23:01:36 -06:00
Mauricio Siu
78c72b6337 refactor: update 2025-02-15 20:49:10 -06:00
Mauricio Siu
e3e35ce792 refactor: update to use organization resources 2025-02-15 20:43:23 -06:00
Mauricio Siu
6d0e195a4d refactor: update 2025-02-15 20:26:05 -06:00
Mauricio Siu
53ce5e57fa refactor: update organization 2025-02-15 20:25:58 -06:00
Mauricio Siu
87b12ff6e9 refactor: update 2025-02-15 20:06:33 -06:00
Mauricio Siu
8b71f963cc refactor: update logic 2025-02-15 19:35:22 -06:00
Mauricio Siu
1c5cc5a0db refactor: update roles 2025-02-15 19:23:08 -06:00
Mauricio Siu
d233f2c764 feat: adjust roles 2025-02-15 19:12:44 -06:00
Mauricio Siu
1bbb4c9b64 refactor: update migration 2025-02-15 18:13:20 -06:00
Mauricio Siu
6ec60b6bab refactor: update validation 2025-02-15 13:14:48 -06:00
Mauricio Siu
55abac3f2f refactor: migrate endpoints 2025-02-14 02:52:37 -06:00
Mauricio Siu
b6c29ccf05 refactor: update 2025-02-14 02:40:11 -06:00
Mauricio Siu
ca217affe6 feat: update references 2025-02-14 02:18:53 -06:00
Mauricio Siu
5c24281f72 refactor: return correct information 2025-02-13 02:45:33 -06:00
Mauricio Siu
bc901bcb25 refactor: update 2025-02-13 02:36:08 -06:00
Mauricio Siu
7c0d223e17 refactor: add fields 2025-02-13 01:42:58 -06:00
Mauricio Siu
74ee024cf9 refactor: update temps 2025-02-13 01:24:25 -06:00
Mauricio Siu
140a871275 refactor: update 2025-02-13 01:21:49 -06:00
Mauricio Siu
d1f72a2e20 refactor: update migration 2025-02-13 00:57:22 -06:00
Mauricio Siu
0d525398a8 feat: migrate rest schemas 2025-02-13 00:45:29 -06:00
Mauricio Siu
7c62408070 refactor: delete 2025-02-13 00:38:39 -06:00
Mauricio Siu
23f1ce17de refactor: add migration 2025-02-13 00:38:22 -06:00
Mauricio Siu
60eee55f2d refactor: test migrastion 2025-02-12 23:41:04 -06:00
Mauricio Siu
8f562eefc1 Merge branch 'canary' into feat/better-auth 2025-02-12 20:56:23 -06:00
Mauricio Siu
6179cef1ee refactor: update name 2025-02-10 02:13:52 -06:00
Mauricio Siu
b7112b89fd refactor: add migration 2025-02-10 00:39:46 -06:00
Mauricio Siu
030c8a312d Update package.json 2025-02-10 00:24:58 -06:00
Mauricio Siu
1db6ba94f4 refactor: remove 2025-02-09 21:36:36 -06:00
Mauricio Siu
afd3d2eea3 refactor: lint 2025-02-09 20:53:14 -06:00
Mauricio Siu
8bd72a8a34 refactor: add organizations system 2025-02-09 20:53:06 -06:00
Mauricio Siu
fafc238e70 refactor: migration 2025-02-09 18:56:17 -06:00
Mauricio Siu
c04bf3c7e0 feat: add migration 2025-02-09 18:19:21 -06:00
Mauricio Siu
6b9fd596e5 feat: add openalternative 2025-02-09 03:17:13 -06:00
Mauricio Siu
7e36433144 Merge pull request #1282 from wish-oss/feat/bulk-actions
feat: added bulk actions for services start and stop and added service status for domain dropdown
2025-02-09 03:07:01 -06:00
Mauricio Siu
0a6554c275 refactor: add loading action 2025-02-09 03:06:18 -06:00
Mauricio Siu
fcc55355f2 refactor: add catch to prevent throw error 2025-02-09 03:02:39 -06:00
Mauricio Siu
78e606876a Merge pull request #1297 from mohabgabber/canary
Update unsend version to v1.3.2
2025-02-09 02:37:31 -06:00
Mauricio Siu
7e99baa267 Merge branch 'canary' into canary 2025-02-09 02:37:23 -06:00
Mauricio Siu
92c03bb7cc Merge pull request #1276 from Dokploy/1004-network-conflict
1004 network conflict
2025-02-09 02:36:17 -06:00
Mauricio Siu
3a5ecb2f64 refactor: remove unused imports 2025-02-09 02:33:30 -06:00
Mauricio Siu
c0a00f4957 refactor: remove dokploy-network 2025-02-09 02:31:01 -06:00
Mauricio Siu
a8f94540f9 refactor: lint 2025-02-09 02:20:40 -06:00
Mauricio Siu
3e2cfe6eb8 refactor: agroupate utilities 2025-02-09 02:20:28 -06:00
Mohab Gabber
b2d5090b36 Merge branch 'canary' of https://github.com/mohabgabber/dokploy into canary 2025-02-09 03:22:27 +02:00
Mohab Gabber
0a0f53e9de chore: update unsend version to v1.3.2 2025-02-09 03:22:23 +02:00
Vishal kadam
17ce03e529 Merge branch 'Dokploy:canary' into feat/bulk-actions 2025-02-09 01:47:55 +05:30
Mauricio Siu
f44512a437 refactor: add condition to deploy on remote servers 2025-02-06 01:52:53 -06:00
Mauricio Siu
8379068fe3 refactor: remove services 2025-02-06 00:40:03 -06:00
Mauricio Siu
a71de72a3c refactor: remove services 2025-02-06 00:39:42 -06:00
Mauricio Siu
b024060eed refactor: delete unneeded container_name 2025-02-06 00:38:04 -06:00
Mauricio Siu
56b26ce0d5 refactor: use appname in network connect 2025-02-06 00:19:34 -06:00
Mauricio Siu
a9e3a65782 Merge branch 'canary' into 1004-network-conflict 2025-02-06 00:17:26 -06:00
Mauricio Siu
7a472df753 Merge pull request #1239 from NagariaHussain/template-frappe-hr
feat(template): frappe HR, open source HR & Payroll software
2025-02-06 00:14:59 -06:00
vishalkadam47
bd809c8dca feat: added bulk actions for services start and stop and added service status for domain dropdown 2025-02-05 08:17:15 +05:30
Hussain Nagaria
48642979c5 chore: make erpnext template more configurable 2025-02-04 17:17:43 +05:30
Hussain Nagaria
46411a5f4e fix: create site should use configured db 2025-02-04 14:30:55 +05:30
Hussain Nagaria
82cf0643d7 fix: site volume configurable 2025-02-04 14:15:47 +05:30
Hussain Nagaria
65780ee852 feat: make db configurable 2025-02-04 13:57:49 +05:30
Mauricio Siu
9d988c9a9b Update package.json 2025-02-03 21:49:20 -06:00
Mauricio Siu
eb211b933e Merge pull request #1277 from Blueshadow58/revert-1259-pocketbase
revert "feat<templates>: Updated PocketBase version to 0.25.0" #1259
2025-02-03 21:47:59 -06:00
Franco Gamonal
20eb6d7985 Revert "feat<templates>: Updated PocketBase version to 0.25.0" 2025-02-03 10:27:35 -03:00
Mauricio Siu
d424524d69 refactor: lint 2025-02-03 00:57:27 -06:00
Mauricio Siu
6f2148c060 feat: add deployable option to randomize and prevent colission in duplicate templates 2025-02-03 00:57:18 -06:00
Mauricio Siu
97b77e526d Merge pull request #1274 from Dokploy/fix/compose-builder
fix: add missing command
2025-02-02 22:19:05 -06:00
Mauricio Siu
077f47f2d8 chore: bump version 2025-02-02 22:18:47 -06:00
Mauricio Siu
5d6847b970 fix: add missing command 2025-02-02 22:18:21 -06:00
Mauricio Siu
2630a73bd8 Merge pull request #1268 from mohabgabber/canary
chore: update unsend image version to v1.2.5
2025-02-02 20:26:24 -06:00
Mauricio Siu
df5ef4a34e Merge pull request #1269 from nktnet1/superset-unofficial-template
fix(template): superset SQLALCHEMY_EXAMPLES_URI env + warning
2025-02-02 20:26:11 -06:00
Mauricio Siu
48a8c6021d chore: bump version 2025-02-02 20:25:45 -06:00
Mauricio Siu
d84a22fa72 feat: add monitoring arm-amd version 2025-02-02 20:23:49 -06:00
Mauricio Siu
1661022d56 Fix/monitoring (#1271)
* refactor: make request to dokploy server to proxy requests

* refactor: lint

* refactor: use dokploy/monitoring tag image

* refactor: use canary in development or canary tags

* refactor: adjust logic

* refactor: update name
2025-02-02 19:51:19 -06:00
Mauricio Siu
2a295d6492 Merge branch 'canary' into fix/monitoring 2025-02-02 19:50:28 -06:00
Mauricio Siu
51851567db refactor: update name 2025-02-02 19:49:30 -06:00
Mauricio Siu
d1aaeb9a7b refactor: adjust logic 2025-02-02 19:43:28 -06:00
Mauricio Siu
f9b4035c20 refactor: use canary in development or canary tags 2025-02-02 19:41:07 -06:00
Mauricio Siu
d492ff87f2 Fix/monitoring (#1270)
* refactor: make request to dokploy server to proxy requests

* refactor: lint

* refactor: use dokploy/monitoring tag image
2025-02-02 19:35:36 -06:00
Mauricio Siu
f638f49ab6 refactor: use dokploy/monitoring tag image 2025-02-02 19:33:52 -06:00
Mauricio Siu
d1610855bb refactor: lint 2025-02-02 19:32:45 -06:00
Mauricio Siu
0c8c0844b1 refactor: make request to dokploy server to proxy requests 2025-02-02 19:32:37 -06:00
Tam Nguyen
98b19bb433 chore(template): resolve conflicts with main 2025-02-03 11:54:09 +11:00
Tam Nguyen
d8c5244d19 fix(template): superset SQLALCHEMY_EXAMPLES_URI env + warning 2025-02-03 11:47:57 +11:00
Mohab Gabber
7bb8456cb7 chore: update unsend image version to v1.2.5 2025-02-03 02:13:44 +02:00
Mauricio Siu
74a0f5e992 Feat/monitoring (#1267) Cloud Version
* feat: add start monitoring remote servers

* reafctor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor:

* refactor: add metrics

* feat: add disk monitoring

* refactor: translate to english

* refacotor: add stats

* refactor: remove color

* feat: add log server metrics

* refactor: remove unused deps

* refactor: add origin

* refactor: add logs

* refactor: update

* feat: add series monitoring

* refactor: add system monitoring

* feat: add benchmark to optimize data

* refactor: update fn

* refactor: remove comments

* refactor: update

* refactor: exclude items

* feat: add refresh rate

* feat: add monitoring remote servers

* refactor: update

* refactor: remove unsued volumes

* refactor: update monitoring

* refactor: add more presets

* feat: add container metrics

* feat: add docker monitoring

* refactor: update conversion

* refactor: remove unused code

* refactor: update

* refactor: add docker compose logs

* refactor: add docker cli

* refactor: add install curl

* refactor: add get update

* refactor: add monitoring remote servers

* refactor: add containers config

* feat: add container specification

* refactor: update path

* refactor: add server filter

* refactor: simplify logic

* fix: verify if file exist before get stats

* refactor: update

* refactor: remove unused deps

* test: add test for containers

* refactor: update

* refactor add memory collector

* refactor: update

* refactor: update

* refactor: update

* refactor: remove

* refactor: add memory

* refactor: add server memory usage

* refactor: change memory

* refactor: update

* refactor: update

* refactor: add container metrics

* refactor: comment code

* refactor: mount proc bind

* refactor: change interval with node cron

* refactor: remove opening file

* refactor: use streams

* refactor: remove unused ws

* refactor: disable live when is all

* refactor: add sqlite

* refactor: update

* feat: add golang benchmark

* refactor: update go

* refactor: update dockerfile

* refactor: update db

* refactor: add env

* refactor: separate logic

* refactor: split logic

* refactor: update logs

* refactor: update dockerfile

* refactor: hide .env

* refactor: update

* chore: hide ,.ebnv

* refactor: add end angle

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update monitoring

* refactor: add mount db

* refactor: add metrics and url callback

* refactor: add middleware

* refactor: add threshold property

* feat: add memory and cpu threshold notification

* feat: send notifications to the server

* feat: add metrics for dokploy server

* refactor: add dokploy server to monitoring

* refactor: update methods

* refactor: add admin to useeffect

* refactor: stop monitoring containers if elements are 0

* refactor: cancel request if appName is empty

* refactor: reuse methods

* chore; add feat monitoring

* refactor: set base url

* refactor: adjust monitoring

* refactor: delete migrations

* feat: add columns

* fix: add missing flag

* refactor: add free metrics

* refactor: add paid monitoring

* refactor: update methods

* feat: improve ui

* feat: add container stats

* refactor: add all container metrics

* refactor: add color primary

* refactor: change default rate limiting refresher

* refactor: update retention days

* refactor: use json instead of individual properties

* refactor: lint

* refactor: pass json env

* refactor: update

* refactor: delete

* refactor: update

* refactor: fix types

* refactor: add retention days

* chore: add license

* refactor: create db

* refactor: update path

* refactor: update setup

* refactor: update

* refactor: create files

* refactor: update

* refactor: delete

* refactor: update

* refactor: update token metrics

* fix: typechecks

* refactor: setup web server

* refactor: update error handling and add monitoring

* refactor: add local storage save

* refactor: add spacing

* refactor: update

* refactor: upgrade drizzle

* refactor: delete

* refactor: uppgrade drizzle kit

* refactor: update search with jsonB

* chore: upgrade drizzle

* chore: update packages

* refactor: add missing type

* refactor: add serverType

* refactor: update url

* refactor: update

* refactor: update

* refactor: hide monitoring on self hosted

* refactor: update server

* refactor: update

* refactor: update

* refactor: pin node version
2025-02-02 14:08:06 -06:00
Mauricio Siu
8c69d2a085 Merge pull request #1265 from Dokploy/1199-error-log-spam-in-traefik-logs-err-errorservice-dokploy-traefik-error-port-is-missing
refactor: delete label
2025-02-01 23:05:24 -06:00
Mauricio Siu
c8a4a826ca refactor: delete label 2025-02-01 23:04:31 -06:00
Mauricio Siu
e1b114a63b Merge pull request #1264 from Dokploy/1202-dates-are-not-shown-in-logs
fix: don't cut log
2025-02-01 23:00:45 -06:00
Mauricio Siu
0b4d19abd6 fix: don't cut log 2025-02-01 23:00:28 -06:00
Mauricio Siu
1c0f6a8e60 Merge pull request #1262 from Dokploy/1235-monitoring-404-not-found---mcpuvalue-is-null
refactor: update docker stats
2025-02-01 21:49:03 -06:00
Mauricio Siu
c41aa0ccf7 refactor: uypdate 2025-02-01 19:31:44 -06:00
Mauricio Siu
96bb72eb99 refactor: update docker stats 2025-02-01 19:27:10 -06:00
Mauricio Siu
ee2fed07b2 Merge pull request #1252 from mateodemuynck/fix/stack-domains
fix: expose docker stack services
2025-02-01 13:50:19 -06:00
Mauricio Siu
af083ffa5d refactor: remove duplicate case 2025-02-01 13:38:24 -06:00
Mauricio Siu
f14ed5170d Merge pull request #1249 from ali-issa/fix/domains-page-ux-improvements
fix: improve domains page UX and button placement consistency
2025-02-01 13:32:45 -06:00
Mauricio Siu
cd1c7e60bf refactor: update domains 2025-02-01 13:32:10 -06:00
Mauricio Siu
79c6b7389c Merge pull request #1259 from Blueshadow58/pocketbase
feat<templates>: Updated PocketBase version to 0.25.0
2025-02-01 11:20:40 -06:00
Mauricio Siu
e48f1431a9 Merge pull request #1238 from wish-oss/feat/copy-ip
feat: added clipboard functionality to copy IP address from badge in services pages
2025-02-01 11:20:11 -06:00
blueshadow58
c2ecdb2d76 feat<templates>: Updated PocketBase version to 0.25.0 2025-02-01 13:25:33 -03:00
Vishal kadam
5c889e81a9 Merge branch 'Dokploy:canary' into feat/copy-ip 2025-02-01 14:12:07 +00:00
Mauricio Siu
407e2e1137 Merge pull request #1250 from MuhammadM1998/patch-1
chore: remove duplicated sentence in projects dialog
2025-02-01 02:41:21 -06:00
vishalkadam47
2c6c89e4c1 refactor: added copy-to-clipboard library and replaced navigator.clipboard.writeText with copy function 2025-01-31 20:31:16 +05:30
Vishal kadam
41a8014585 Merge branch 'Dokploy:canary' into feat/copy-ip 2025-01-31 14:12:31 +00:00
Motysten
fffe1d6249 fix: labels not added correctly when using "stack" type docker dompose file 2025-01-31 14:15:52 +01:00
Muhammad Mahmoud
cf0f5c8b97 chore: remove duplicated sentence 2025-01-31 13:55:18 +02:00
Ali Issa
777980618f fix: improve domains page UX and button placement consistency 2025-01-31 02:59:23 -05:00
Mauricio Siu
dcd1df31c7 Merge pull request #637 from xenonwellz/feat/stack-env-support
Feat: added env support to Dokploy stack compose
2025-01-31 01:38:29 -06:00
Mauricio Siu
7369b54f32 refactor: update 2025-01-31 01:20:52 -06:00
Mauricio Siu
009859faa9 refactor: add .env docker stack 2025-01-31 01:20:10 -06:00
Mauricio Siu
f7a29accb1 refactor: lint 2025-01-30 23:40:01 -06:00
Mauricio Siu
50c518a63a Merge branch 'canary' into feat/stack-env-support 2025-01-30 23:39:54 -06:00
Mauricio Siu
79fca72d06 Merge branch 'canary' into template-frappe-hr 2025-01-30 23:32:56 -06:00
Mauricio Siu
18c6d08b9a Merge pull request #1237 from bernabedev/canary
feat(template): add Formbricks template with Docker Compose and environment setup
2025-01-30 23:32:32 -06:00
Mauricio Siu
208094cd3e Update apps/dokploy/templates/formbricks/index.ts 2025-01-30 23:31:43 -06:00
Mauricio Siu
1342f73a02 Update apps/dokploy/templates/formbricks/index.ts 2025-01-30 23:31:39 -06:00
Mauricio Siu
1787c524f0 Update apps/dokploy/templates/formbricks/docker-compose.yml 2025-01-30 23:31:33 -06:00
Mauricio Siu
5899dc9394 Merge pull request #1114 from joaotonaco/feat/template-evolution-api
feat(template): added evolution api
2025-01-30 23:24:25 -06:00
Mauricio Siu
6b48c0f354 fix: correct env values 2025-01-30 23:24:12 -06:00
Mauricio Siu
33f3d1d87e Merge branch 'canary' into feat/template-evolution-api 2025-01-30 23:16:09 -06:00
Mauricio Siu
4bfb172373 Merge pull request #1232 from muhammetakalan/patch-1
fix: teable template
2025-01-30 23:11:34 -06:00
Mauricio Siu
6cf96df6ec Merge pull request #1231 from jannismilz/canary
feat: upgrade listmonk version
2025-01-30 23:09:01 -06:00
Hussain Nagaria
62a3707c10 feat(template): frappe HR, open source HR & Payroll software 2025-01-29 18:49:27 +05:30
Freilyn Bernabe
00d2b3b572 fix: changing the description of formbricks to english 2025-01-29 07:26:14 -04:00
vishalkadam47
d8f1548076 feat: added clipboard functionality to copy IP address from badge in services pages 2025-01-29 13:09:58 +05:30
Freilyn Bernabe
de4d1c0911 feat(template): add Formbricks template with Docker Compose and environment setup 2025-01-29 00:39:01 -04:00
Muhammet Emin Akalan
b96169fa55 fix: teable template 2025-01-28 15:31:20 +03:00
Jannis Milz
e21e0e1865 feat: Upgrade listmonk version and remove automatic admin credentials creation 2025-01-28 12:24:54 +01:00
Mauricio Siu
dc9a194bbe Merge pull request #1229 from nktnet1/shlink-template
fix(template): shlink version stable in index.ts
2025-01-27 22:57:48 -06:00
Khiet Tam Nguyen
27738d253e fix(template): shlink version stable in index.ts 2025-01-28 01:47:14 +11:00
Mauricio Siu
d37bde00bc Merge pull request #1226 from nktnet1/shlink-template
feat(template): added shlink, a url shortener service
2025-01-26 23:44:13 -06:00
Khiet Tam Nguyen
55fae23ce3 feat(template): added shlink, a url shortener service 2025-01-27 14:36:07 +11:00
Mauricio Siu
d43cd52762 refactor: update 2025-01-26 19:58:35 -06:00
Mauricio Siu
094491ecbf refactor: add missing deps 2025-01-26 19:47:26 -06:00
Mauricio Siu
3d5bed0915 chore: add missing dependency 2025-01-26 19:30:21 -06:00
Mauricio Siu
23de14f0b4 chore: bump version 2025-01-26 19:15:11 -06:00
Mauricio Siu
215811ae82 Merge pull request #1224 from Dokploy/1093-expose-deploy-preview-url-in-env
feat: add DOKPLOY_DEPLOY_URL in enviroment variables for previews
2025-01-26 19:14:01 -06:00
Mauricio Siu
34b4956630 feat: add DOKPLOY_DEPLOY_URL in enviroment variables for previews 2025-01-26 19:13:43 -06:00
Mauricio Siu
7ed0282ce1 Merge pull request #1223 from Dokploy/fix/remove-cron-jobs-after-delete
Fix/remove cron jobs after delete
2025-01-26 19:01:32 -06:00
Mauricio Siu
24327139b8 refactor: make optional field name 2025-01-26 19:00:58 -06:00
Mauricio Siu
0fb67ced5d fix: remove cron jobs after delete service 2025-01-26 18:59:27 -06:00
Mauricio Siu
1e56364f93 refactor: update typo 2025-01-26 18:26:49 -06:00
Mauricio Siu
f980e459d9 Merge pull request #1220 from bernabedev/canary
feat(template): added answer template
2025-01-26 17:57:40 -06:00
Mauricio Siu
3209550edc Update apps/dokploy/templates/answer/docker-compose.yml 2025-01-26 17:57:31 -06:00
Mauricio Siu
9835ead2b9 Merge pull request #1222 from Dokploy/1164-when-creating-a-service-and-selecting-a-server-display-description-information
refactor: add server IP address
2025-01-26 17:50:07 -06:00
Mauricio Siu
73f93f8a13 refactor: add server IP address 2025-01-26 17:49:51 -06:00
Mauricio Siu
46ddaa68fa Merge pull request #1221 from Dokploy/1170-0176-copy-to-clipboard-stopped-working-on-the-project-delete-confirm-dialog
refactor: add back delete with confirmation
2025-01-26 17:45:03 -06:00
Mauricio Siu
64e68cfde1 refactor: update styles 2025-01-26 17:44:31 -06:00
Mauricio Siu
36c1a615c6 refactor: use copy package 2025-01-26 17:42:04 -06:00
Mauricio Siu
eeb97645b5 refactor: add back delete with confirmation 2025-01-26 17:40:28 -06:00
Freilyn Bernabe
9a052c4657 Merge branch 'Dokploy:canary' into canary 2025-01-26 19:25:44 -04:00
Freilyn Bernabe
165cdd27da feat(template): added answer template 2025-01-26 19:22:17 -04:00
Mauricio Siu
13551f6065 Merge pull request #1219 from Dokploy/706-ability-to-use-a-custom-port-for-git
fix: set extracted port on ssh connection
2025-01-26 17:21:09 -06:00
Mauricio Siu
f2ad1c5a57 fix: set extracted port on ssh connection 2025-01-26 17:20:33 -06:00
Mauricio Siu
4d5565895c Merge pull request #1218 from Dokploy/1059-inconsistence-layout-resource
refactor: adjust order
2025-01-26 16:28:17 -06:00
Mauricio Siu
8e51dedb78 refactor: adjust order 2025-01-26 16:28:00 -06:00
Mauricio Siu
780fa6b9cd Merge pull request #1217 from Dokploy/1177-when-deploying-applications-through-autodeploy-docker-cannot-update
refactor: make less aggressive cleanups
2025-01-26 16:25:16 -06:00
Mauricio Siu
95e642e63a refactor: make less aggressive cleanups 2025-01-26 16:20:02 -06:00
Mauricio Siu
5801c69034 Merge pull request #1216 from Dokploy/1190-cant-reach-my-app-by-assigned-domain
refactor: add dokploy-network on new installations to prevent gateway…
2025-01-26 15:54:47 -06:00
Mauricio Siu
2c98b4267f refactor: add dokploy-network on new installations to prevent gateway 504 errors randomly 2025-01-26 15:53:50 -06:00
Mauricio Siu
1874ffaa55 Merge pull request #1215 from Dokploy/1210-search-repository-function-is-not-working-correctly
fix: search by name and not on url
2025-01-26 15:46:11 -06:00
Mauricio Siu
11c4101dc3 fix: search by name and not on url 2025-01-26 15:45:53 -06:00
Mauricio Siu
4c3b5ef271 Merge pull request #1214 from NagariaHussain/template-erpnext
feat: add erpnext template
2025-01-26 15:29:58 -06:00
Mauricio Siu
4105353109 Merge branch 'canary' into template-erpnext 2025-01-26 15:20:30 -06:00
Mauricio Siu
8a971072e4 Merge pull request #1205 from nktnet1/superset-avoid-host-conflict
fix(template): prepend superset_ to postgres/redis
2025-01-26 15:19:46 -06:00
Mauricio Siu
dab12d6162 Merge pull request #1204 from gentslava/fix/issue_1198-muliple_preview_deployments
fix: Multiple Preview Deployments
2025-01-26 15:19:00 -06:00
Mauricio Siu
8d31574e5f refactor: add continue to is cloud 2025-01-26 15:17:26 -06:00
Mauricio Siu
0012ca7357 Merge pull request #1203 from wish-oss/fix/xterm
fix: terminal paste visibility
2025-01-26 15:02:26 -06:00
Mauricio Siu
1aed53e6fe refactor: add missing load 2025-01-26 15:01:49 -06:00
Mauricio Siu
d6b966cfea refactor: merge canary 2025-01-26 15:01:30 -06:00
Mauricio Siu
772341fb1e refactor: remove unused settings 2025-01-26 14:57:33 -06:00
Mauricio Siu
e7a6247297 refactor: lint 2025-01-26 14:56:01 -06:00
Mauricio Siu
b1beb7b71b fix: adjust color paste on terminal 2025-01-26 14:55:22 -06:00
Mauricio Siu
6254644fa6 Merge pull request #1196 from rahadi23/bugfix/1165-navigation-items-permissions
fix(ui): add condition to show update server button to admin only
2025-01-26 14:30:39 -06:00
Mauricio Siu
bcb86f3d6d Merge pull request #1195 from bernabedev/canary
feat(template): added maybe finance
2025-01-26 14:30:17 -06:00
Mauricio Siu
b96103247a Merge branch 'canary' into canary 2025-01-26 14:30:11 -06:00
Mauricio Siu
87697147da Update apps/dokploy/templates/maybe/docker-compose.yml 2025-01-26 14:29:51 -06:00
Mauricio Siu
074e3b6ec6 Merge pull request #1194 from wish-oss/feat/spacedrive-template
feat: add spacedrive file manager template
2025-01-26 14:24:52 -06:00
Mauricio Siu
7061e06736 Merge branch 'canary' into feat/spacedrive-template 2025-01-26 14:24:46 -06:00
Mauricio Siu
de35812d5a Merge pull request #1193 from wish-oss/feat/domain-navigation
feat: restore domain dropdown menu functionality
2025-01-26 14:22:34 -06:00
Mauricio Siu
c5ac5f84b1 Merge pull request #1186 from gentslava/canary
feat(i18n): add missing keys to Russian translation
2025-01-26 14:21:20 -06:00
Mauricio Siu
2e42fa7014 Merge pull request #1185 from RoodyCode/patch-1
style: fix incosistent spacing in Card-Footer
2025-01-26 14:21:05 -06:00
Mauricio Siu
0fe8a1a221 Merge pull request #1183 from yni9ht/feat-alist-template
feat(template): add alist template
2025-01-26 14:20:15 -06:00
Hussain Nagaria
829e77a6b8 feat: add erpnext template
Co-authored-by: Revant <revant.one@gmail.com>
2025-01-26 21:50:10 +05:30
Khiet Tam Nguyen
adfe598671 fix(template): prepend superset_ to postgres/redis 2025-01-25 19:14:58 +11:00
Vyacheslav Shcherbinin
ac49235916 Pull Request actions filter 2025-01-25 10:51:22 +07:00
vishalkadam47
d7210e9d7b fix: terminal paste visibility 2025-01-25 00:42:46 +05:30
Rahadi Jalu
52ea9ffa67 fix(ui): add condition to show update server button to admin only 2025-01-24 15:05:02 +07:00
Freilyn Bernabe
fd570ff861 feat(template): added maybe finance 2025-01-23 23:41:58 -04:00
vishalkadam47
130567dd78 feat: add Spacedrive file manager template 2025-01-24 08:17:39 +05:30
vishalkadam47
0a826fbf1c feat: add dropdown menu for service domains and add download functionality for SSH keys 2025-01-24 06:13:54 +05:30
Vyacheslav Shcherbinin
081d08c20a Update ru translation 2025-01-23 20:20:59 +07:00
Roody
41cf3d7b77 style: fix incosistent spacing 2025-01-23 11:32:08 +01:00
yni9ht
b05af62b7b feat: add alist template 2025-01-23 14:58:45 +08:00
Mauricio Siu
c6569f70e4 Merge pull request #1182 from Dokploy/fix/disable-turn-off-cleanup
fix: turnoff cleanup cache
2025-01-23 00:54:30 -06:00
Mauricio Siu
d5c9338f51 fix: turnoff cleanup cache 2025-01-23 00:53:21 -06:00
Mauricio Siu
dba39b6364 Merge pull request #1180 from Dokploy/feat/add-error-logs-remote-server
Feat/add error logs remote server
2025-01-23 00:50:35 -06:00
Mauricio Siu
22de0fef49 chore: bump version 2025-01-23 00:44:48 -06:00
Mauricio Siu
03e1c17675 feat: add remote logs error when is not reachable 2025-01-23 00:44:31 -06:00
Mauricio Siu
6edd2a81e5 refactor: lint 2025-01-22 23:45:03 -06:00
Mauricio Siu
fe5b8782e9 chore: bump version 2025-01-22 23:44:43 -06:00
Mauricio Siu
71f28fae70 Merge pull request #1176 from wish-oss/feat/refactor-templates
feat:update templates glance and homarr
2025-01-22 23:43:53 -06:00
Mauricio Siu
c44618aa95 Merge pull request #1169 from SHABIN-K/add-malayalam-language
style(i18n) add malayalamlanguage
2025-01-22 23:39:37 -06:00
Mauricio Siu
c7d86dd430 Merge pull request #1178 from Dokploy/1175-preview-deployment-not-found-correctly
fix: set right branch in preview remote deployments
2025-01-22 23:33:29 -06:00
Mauricio Siu
e50bbd1a6a fix: set right branch in preview remote deployments 2025-01-22 23:33:05 -06:00
vishalkadam47
d5ff91563a fix: docker-compose.yaml to docker-compose.yml 2025-01-23 10:08:50 +05:30
vishalkadam47
210fe0759c feat:update templates glance and homarr 2025-01-23 03:43:04 +05:30
Shabin k
edff66900e add malayalam support 2025-01-22 12:26:05 +05:30
Shabin k
4cf2177928 add malayalam support 2025-01-22 12:23:38 +05:30
Shabin k
4a8306b015 Merge branch 'Dokploy:canary' into add-malayalam-language 2025-01-22 12:22:54 +05:30
Shabin k
f92d6693c3 add malayalam support 2025-01-22 12:22:27 +05:30
Mauricio Siu
81248ed03f fix: add continue to process all applications 2025-01-22 00:39:28 -06:00
Mauricio Siu
c7d5900e11 chore: bump version 2025-01-22 00:25:25 -06:00
Mauricio Siu
d0608f43a9 Merge pull request #1166 from rahadi23/bugfix/1165-navigation-items-permissions
fix: filter navigation items based on user's permissions and role
2025-01-22 00:24:55 -06:00
Mauricio Siu
adaf12a9a4 refactor: update 2025-01-22 00:16:00 -06:00
Mauricio Siu
baa2ca20f4 Merge branch 'canary' into bugfix/1165-navigation-items-permissions 2025-01-22 00:15:31 -06:00
Mauricio Siu
537caf02e5 Merge pull request #1167 from Dokploy/fix/creation-users
refactor: make protected instead of admin
2025-01-22 00:14:49 -06:00
Mauricio Siu
02ff507094 refactor: update lint 2025-01-22 00:14:30 -06:00
Mauricio Siu
53df7d969e refactor: make protected instead of admin 2025-01-22 00:13:22 -06:00
Rahadi Jalu
9e6e68558a fix: adjust dialog title based on add/update condition 2025-01-22 11:26:18 +07:00
Rahadi Jalu
a2e9ea2986 fix: add condition to show create project button 2025-01-22 11:26:18 +07:00
Rahadi Jalu
026e1bece6 fix: filter navigation items based on user's permissions and role 2025-01-22 11:26:18 +07:00
Mauricio Siu
51f6e08e16 Merge pull request #1158 from nktnet1/superset-unofficial-template
feat(template): added apache superset (unofficial)
2025-01-21 21:59:04 -06:00
Mauricio Siu
c0f8218ad9 Merge pull request #1161 from nktnet1/volume-file-mount-content-line-clamp
fix(ui): volume file mount content, line clamp and preserve whitespace
2025-01-21 21:51:07 -06:00
Mauricio Siu
35dd6bff7a Merge pull request #1160 from nktnet1/fix-invisible-mount-path
fix(ui): volume mountPath undefined for file mount - meant to display filePath instead?
2025-01-21 21:50:33 -06:00
Mauricio Siu
8dad8fd008 Merge pull request #1162 from mikield/add-ukrainian-language
style(i18n) add ukrainian language
2025-01-21 21:31:33 -06:00
Tam Nguyen
52dbc0d8f1 fix(template): superset healthchecks 2025-01-22 09:14:21 +11:00
Vladyslav G
692f883064 style(i18n) add ukrainian language 2025-01-21 16:21:29 +01:00
Khiet Tam Nguyen
6d90e268f7 fix(ui): volume file mount content, line clamp and preserve whitespace 2025-01-22 01:09:44 +11:00
Khiet Tam Nguyen
1d86f1a0fc fix(template): added analytics tag to superset 2025-01-22 00:05:55 +11:00
Khiet Tam Nguyen
c7338983b8 refactor(ui): clearer ui display condition for volume mount display 2025-01-21 23:45:54 +11:00
Khiet Tam Nguyen
ce06cd42b3 fix(ui): show filePath instead of mountPath for file mounts 2025-01-21 23:41:05 +11:00
Khiet Tam Nguyen
1a44a0ea5a refactor(template): use dokploy mount volume for superset_config.py 2025-01-21 23:14:28 +11:00
Khiet Tam Nguyen
444121f8d8 fix(template): more appropriate tags for superset 2025-01-21 20:40:46 +11:00
Khiet Tam Nguyen
05a75edbec feat(template): added apache superset (unofficial) 2025-01-21 19:03:35 +11:00
Mauricio Siu
f5d81f434c chore: bump package 2025-01-19 13:25:44 -06:00
Mauricio Siu
10353d1f29 Merge pull request #1147 from eltociear/patch-2
chore(logs): update utils.ts
2025-01-19 13:24:54 -06:00
Mauricio Siu
6226c75959 Merge pull request #1148 from Dokploy/fix/clean-cache
Fix/clean cache
2025-01-19 13:24:37 -06:00
Mauricio Siu
42c9cd5901 chore: bump package 2025-01-19 13:24:22 -06:00
Mauricio Siu
49edcdb99e fix: clean cache at the start of deployments 2025-01-19 13:22:23 -06:00
Ikko Eltociear Ashimine
ad71e8b36a chore(logs): update utils.ts
Exemple -> Example
2025-01-20 03:15:02 +09:00
Mauricio Siu
b166cd5bfa refactor: add settings page to user nav 2025-01-19 11:30:44 -06:00
Mauricio Siu
0045608acc chore: remove release title 2025-01-19 11:22:31 -06:00
Mauricio Siu
b140e81210 chore: update release 2025-01-19 11:20:22 -06:00
Mauricio Siu
9298d6c693 Merge pull request #1144 from Dokploy/canary
🚀 Release v0.17.3
2025-01-19 11:11:36 -06:00
Mauricio Siu
23df3fba85 refactor: regenerate migrations 2025-01-19 11:01:41 -06:00
Mauricio Siu
93c7f1ce76 chore: update 2025-01-19 10:56:43 -06:00
Mauricio Siu
1323394589 chore: remove draft 2025-01-19 10:51:05 -06:00
Mauricio Siu
4f11fc2547 chore: remove spaces 2025-01-19 10:49:08 -06:00
Mauricio Siu
6d052ad455 chore: add quotes 2025-01-19 10:47:04 -06:00
Mauricio Siu
60748da144 chore: update 2025-01-19 10:45:51 -06:00
Mauricio Siu
1956836cde refactor: update 2025-01-19 10:45:12 -06:00
Mauricio Siu
7dca4fe430 refactor: update 2025-01-19 10:43:32 -06:00
Mauricio Siu
84690c5f75 chore: update pr 2025-01-19 10:41:44 -06:00
Mauricio Siu
95b67ef2e9 chore: update 2025-01-19 10:36:47 -06:00
Mauricio Siu
3f8bc47ce5 refactor: update 2025-01-19 10:35:07 -06:00
Mauricio Siu
498678c4ae Revert "refactor: update"
This reverts commit 3d602c232d.
2025-01-19 10:21:30 -06:00
Mauricio Siu
3d602c232d refactor: update 2025-01-19 10:19:59 -06:00
Mauricio Siu
94ffa7d578 chore: update 2025-01-19 10:15:28 -06:00
Mauricio Siu
64fc3c7677 chore: remove pr 2025-01-19 10:14:58 -06:00
Mauricio Siu
f27830daf0 refactor: update version 2025-01-19 10:08:53 -06:00
Mauricio Siu
3ec2e2dd1a Revert "chore: bump version"
This reverts commit 1e006cb094.
2025-01-19 10:06:50 -06:00
Mauricio Siu
1e006cb094 chore: bump version 2025-01-19 10:00:54 -06:00
Mauricio Siu
8aa655af2c chore: add pr 2025-01-19 10:00:09 -06:00
Mauricio Siu
65659e27f1 Merge pull request #1137 from Dokploy/feat/github-runners
Feat/GitHub runners
2025-01-19 09:58:27 -06:00
Mauricio Siu
4b6f9108d4 refactor: update 2025-01-19 02:56:08 -06:00
Mauricio Siu
1e4a41a8e3 chore: replace circle with github actions 2025-01-19 02:55:05 -06:00
Mauricio Siu
539aa7a85b refactor: print version 2025-01-19 02:47:14 -06:00
Mauricio Siu
b6ae502b92 refactor: update 2025-01-19 02:38:38 -06:00
Mauricio Siu
e82db47ec4 refactor: add github docker 2025-01-19 02:33:53 -06:00
Mauricio Siu
f9b4f008c2 Merge pull request #1134 from Dokploy/1031-excessive-unused-docker-cache-generated-by-dokploy-deployments
feat: add cleanup cache on deployments
2025-01-19 02:08:48 -06:00
Mauricio Siu
adb204ec1f refactor: add sidebar persistence 2025-01-19 01:44:42 -06:00
Mauricio Siu
5310a559b0 refactor: improve sidebar 2025-01-19 01:29:29 -06:00
Mauricio Siu
43b7db00f9 refactor: add missing values to test 2025-01-19 01:21:03 -06:00
Mauricio Siu
52c83fd6fc refactor: update text 2025-01-19 01:07:41 -06:00
Mauricio Siu
25a8df567e feat: add cleanup cache on deployments 2025-01-19 00:57:42 -06:00
Mauricio Siu
089274492d Merge pull request #1125 from SlavenIvanov/feat/add-couch-db-template
feat: added couchdb template
2025-01-18 17:39:12 -06:00
Mauricio Siu
65c0ea829f Merge branch 'canary' into feat/add-couch-db-template 2025-01-18 17:39:02 -06:00
Mauricio Siu
d060eec465 Update apps/dokploy/templates/couchdb/docker-compose.yml 2025-01-18 17:38:33 -06:00
Mauricio Siu
2dca0d343e Merge pull request #1069 from nktnet1/accessed-typo
chore: typo "accesed" changed to "accessed" for TS code
2025-01-18 17:35:04 -06:00
Mauricio Siu
a8f63bb4d3 Merge pull request #1131 from agustints/fix/db-backups-destination-url
fix: Update link text and destination for db backup settings
2025-01-18 17:34:09 -06:00
Mauricio Siu
cecd371988 Merge pull request #1081 from depado/gotify-notifications
feat(notifications): implement gotify provider
2025-01-18 17:33:55 -06:00
Mauricio Siu
6c4d94cb4f Merge pull request #1118 from izayl/feature/ci-skip
feat: add ci skip check
2025-01-18 17:30:41 -06:00
Mauricio Siu
c4e5c818f3 Merge branch 'canary' into gotify-notifications 2025-01-18 17:23:35 -06:00
Mauricio Siu
31a35d91e8 Merge pull request #1088 from shiqocred/canary
Update telegram message notification
2025-01-18 17:22:50 -06:00
Mauricio Siu
74a2f79a36 Merge pull request #1123 from nktnet1/it-tools-template
feat(template): added it-tools
2025-01-18 11:02:42 -06:00
Mauricio Siu
a8f8a727bd Merge pull request #1128 from TheLetslook/patch-1
feat(style): custom scrollbar styling
2025-01-18 11:00:42 -06:00
Mauricio Siu
e8f2ab35c9 Merge pull request #1121 from thebadking/canary
style: fix tablet and mobile (Create from Template)
2025-01-18 10:52:24 -06:00
depado
9806a5d607 feat(notifications): fix gotify style 2025-01-18 14:23:53 +01:00
depado
e25d0c0c68 feat(notifications): implement notifications for gotify 2025-01-18 14:23:27 +01:00
depado
1f8a476264 chore(lint): run biome 2025-01-18 14:23:27 +01:00
depado
cc473b3e87 feat(notifications): implement gotify provider 2025-01-18 14:23:27 +01:00
Agustin Tornielli
10b3543d39 fix: Update link text and destination for db backup settings 2025-01-18 01:19:33 -03:00
thebadking
0893149db0 style: grid fix 2025-01-17 20:45:17 +00:00
Vasiliy
e257f86194 feat(style): custom scrollbar styling 2025-01-17 21:49:44 +03:00
Slaven Ivanov
a9a0b4cb03 feat: added couchdb template 2025-01-17 18:09:02 +02:00
Khiet Tam Nguyen
c5073c9f30 feat(template): added it-tools 2025-01-18 01:02:39 +11:00
João Gabriel
34ab01fcae Merge branch 'canary' into feat/template-evolution-api 2025-01-17 10:38:14 -03:00
João Gabriel
df43f8318a fix: evolution api database provider 2025-01-17 10:37:01 -03:00
izayl
e69c602d1c chore: enhance deployment skip message to include reason for skipping 2025-01-17 15:47:18 +08:00
izayl
0116d995d9 test: extractCommitMessage 2025-01-17 15:32:59 +08:00
Mauricio Siu
ad479c4be1 Merge pull request #1112 from nktnet1/conduwuit-template
feat(template): added conduwuit
2025-01-16 22:24:46 -06:00
Mauricio Siu
f70192a71c refactor: lint 2025-01-16 22:24:31 -06:00
Mauricio Siu
abff70f081 refactor: lint 2025-01-16 22:24:17 -06:00
Mauricio Siu
9f03faaec2 Merge branch 'canary' into conduwuit-template 2025-01-16 22:24:03 -06:00
Mauricio Siu
1d760bd25f Merge pull request #1104 from nktnet1/cloudflared-template
feat(template): added cloudflared
2025-01-16 22:20:59 -06:00
Mauricio Siu
26a67dd175 Merge pull request #1119 from wish-oss/extrernal-link
refactor: update icon handling and restructure sidebar external links
2025-01-16 22:15:57 -06:00
thebadking
013ee89a56 style: fix tablet and mobile (Create from Template) 2025-01-16 21:12:11 +00:00
vishalkadam47
c3d3c7b96a refactor: update icon handling and restructure sidebar external links 2025-01-16 19:31:55 +05:30
izayl
4e724d88aa feat: add ci skip check 2025-01-16 16:26:28 +08:00
Mauricio Siu
351e4b6103 Merge pull request #1115 from TheLetslook/patch-1
fix: db type typo
2025-01-15 23:30:29 -06:00
Vasiliy
5b633ec6c5 fix: db type typo
Postgres -> Mongo
2025-01-14 23:25:22 +03:00
João Gabriel
a46cbf4f2c feat: evolution api template 2025-01-14 08:36:29 -03:00
Khiet Tam Nguyen
ca9552d618 chore: sort conduwuit tags 2025-01-14 17:30:45 +11:00
Khiet Tam Nguyen
23b40c1aa7 feat(template): added conduwuit 2025-01-14 17:21:05 +11:00
Mauricio Siu
5e0b7ba143 Merge pull request #1109 from 190km/fix/sidebar
fix: fixed missing cluster side item
2025-01-13 23:50:02 -06:00
Mauricio Siu
cb1203e302 refactor: exclude cluster from cloud 2025-01-13 23:49:22 -06:00
Mauricio Siu
373c5bc507 Merge pull request #1106 from DJKnaeckebrot/fix/styling-after-sidebar-fixes
fix(style): clean up sidebar related style issue
2025-01-13 23:41:00 -06:00
Mauricio Siu
ee5afa4793 Merge pull request #1110 from szwabodev/fix/wslCheck
fix: isWSL check was not awaited
2025-01-13 22:58:15 -06:00
Mauricio Siu
7b9426586d Merge pull request #1107 from joaotonaco/refactor/improve_sidebar_animation
refactor(sidebar): improve animation
2025-01-13 22:38:41 -06:00
190km
772b24a415 fix: fixed missing cluster side item 2025-01-13 14:12:19 +00:00
UndefinedPony
1922437115 fix: isWSL check was not awaited causing always true 2025-01-13 14:48:13 +01:00
João Gabriel
c5eb8b087d refactor: transition logo size 2025-01-13 09:20:02 -03:00
João Gabriel
94ee5391fb refactor: apply ease-out to sidebar toggle animation 2025-01-13 09:19:32 -03:00
djknaeckebrot
b3ff14f792 style: clean up class names for consistency in dashboard components 2025-01-13 12:09:06 +01:00
djknaeckebrot
b68273c8ca style: adjust grid layout for monitoring card and clean up class names 2025-01-13 12:02:59 +01:00
djknaeckebrot
8b203c48b4 feat: update sidebar with Traefik File System title and add support link 2025-01-13 11:55:58 +01:00
djknaeckebrot
fb33a5b6a5 style: make sure card do use the full width of the div 2025-01-13 11:53:05 +01:00
Khiet Tam Nguyen
b17ab6d8cb feat(template): added cloudflared 2025-01-13 20:02:18 +11:00
shiqocred
2de0e73284 Merge branch 'Dokploy:canary' into canary 2025-01-13 13:31:31 +07:00
shiqocred
537950dd9f Revision notification (#7)
* Update build-success.ts

* Update compose.ts

* Update application.ts

* Update notification.ts

* Update utils.ts

* Update dokploy-restart.ts

* Update docker-cleanup.ts

* Update database-backup.ts

* Update build-success.ts

* Update build-success.ts
2025-01-13 13:13:13 +07:00
shiqocred
d2094d6d76 Update notification.ts (#6)
fix router notification
2025-01-12 02:14:09 +07:00
shiqocred
c0b8a411bd Update build-success.ts (#5)
add format
2025-01-12 02:04:10 +07:00
shiqocred
1d8db07fa1 Add inline button telegram (#4)
* Update utils.ts

add type inline button

* Update dokploy-restart.ts

fixing format massage and adding [] for inline button type

* Update docker-cleanup.ts

fixing telegram message

* Update database-backup.ts

fixing telegram message

* Update build-error.ts

fixing message and adding button logs view

* Update build-success.ts

fixing message, adding domains props, adding inline button

* Update compose.ts

adding get domains compose and send to notif

* Update application.ts

adding get domains and send it to notif

* Update build-success.ts

fix space

* Update dokploy-restart.ts

fixing space
2025-01-12 01:20:39 +07:00
shiqocred
dd3618bfd9 Add inline button telegram (#3)
* Update utils.ts

add type inline button

* Update dokploy-restart.ts

fixing format massage and adding [] for inline button type

* Update docker-cleanup.ts

fixing telegram message

* Update database-backup.ts

fixing telegram message

* Update build-error.ts

fixing message and adding button logs view

* Update build-success.ts

fixing message, adding domains props, adding inline button

* Update compose.ts

adding get domains compose and send to notif

* Update application.ts

adding get domains and send it to notif
2025-01-12 00:55:31 +07:00
Tam Nguyen
4a9d7225c9 chore: typo "accesed" changed to "accessed" for TS code 2025-01-08 13:44:59 +11:00
Mauricio Siu
79b733536f Merge branch 'canary' into feat/stack-env-support 2024-12-08 18:35:40 -06:00
xenonwellz
65ee0a3e22 fix(builder): created processed file in the same directory as main stack.yml 2024-11-11 02:22:48 +01:00
xenonwellz
c9b570e469 fix(builder): fixed issues on non-raw compose and external servers 2024-11-09 13:00:15 +01:00
xenonwellz
dafed3096f refactor(builder): removed path log 2024-11-04 22:47:58 +01:00
xenonwellz
f772fec407 fix(bundler): docker-compose bug 2024-11-04 11:02:46 +01:00
xenonwellz
06cbd1fce1 refactor(bundler): removed redundant code 2024-11-03 15:26:54 +01:00
xenonwellz
9c355bcfb7 refactor(builder): removed unused and redundant code 2024-11-02 12:42:53 +01:00
xenonwellz
06081627e8 refactor: used docker stack config 2024-11-02 00:35:38 +01:00
xenonwellz
dc1e12d6ed feat(compose): added stop functionality for stack 2024-11-01 23:35:42 +01:00
xenonwellz
cb02deb837 fix(builder): fixed docker-compose issue 2024-11-01 23:15:12 +01:00
xenonwellz
94786c738b feat: added env support for dokploy 2024-11-01 15:27:00 +01:00
492 changed files with 130576 additions and 15078 deletions

View File

@@ -1,119 +0,0 @@
version: 2.1
jobs:
build-amd64:
machine:
image: ubuntu-2004:current
steps:
- checkout
- run:
name: Prepare .env file
command: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- run:
name: Build and push AMD64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
else
TAG="feature"
fi
docker build --platform linux/amd64 -t dokploy/dokploy:${TAG}-amd64 .
docker push dokploy/dokploy:${TAG}-amd64
build-arm64:
machine:
image: ubuntu-2004:current
resource_class: arm.large
steps:
- checkout
- run:
name: Prepare .env file
command: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- run:
name: Build and push ARM64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
else
TAG="feature"
fi
docker build --platform linux/arm64 -t dokploy/dokploy:${TAG}-arm64 .
docker push dokploy/dokploy:${TAG}-arm64
combine-manifests:
docker:
- image: cimg/node:20.9.0
steps:
- checkout
- setup_remote_docker
- run:
name: Create and push multi-arch manifest
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo $VERSION
TAG="latest"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
docker manifest create dokploy/dokploy:${VERSION} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${VERSION}
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
else
TAG="feature"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
fi
workflows:
build-all:
jobs:
- build-amd64:
filters:
branches:
only:
- main
- canary
- feat/add-sidebar
- build-arm64:
filters:
branches:
only:
- main
- canary
- feat/add-sidebar
- combine-manifests:
requires:
- build-amd64
- build-arm64
filters:
branches:
only:
- main
- canary
- feat/add-sidebar

BIN
.github/sponsors/openalternative.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

83
.github/workflows/create-pr.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Auto PR to main when version changes
on:
push:
branches:
- canary
permissions:
contents: write
pull-requests: write
jobs:
create-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from package.json
id: package_version
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
- name: Get latest GitHub tag
id: latest_tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
- name: Compare versions
id: compare_versions
run: |
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
id: check_pr
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
- name: Create Pull Request
if: env.VERSION_CHANGED == 'true' && env.PR_EXISTS == '0'
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout canary
git push origin canary
gh pr create \
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to ${{ env.VERSION }}
- All changes from canary branch
### ✅ Pre-merge Checklist:
- [ ] All tests passing
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
--reviewer siumauricio \
--assignee siumauricio
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -2,7 +2,7 @@ name: Build Docker images
on:
push:
branches: ["canary", "main"]
branches: ["canary", "main", "feat/monitoring"]
jobs:
build-and-push-cloud-image:
@@ -17,7 +17,7 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
@@ -53,8 +53,7 @@ jobs:
push: true
tags: |
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
platforms: linux/amd64
build-and-push-server-image:
runs-on: ubuntu-latest
@@ -77,4 +76,4 @@ jobs:
push: true
tags: |
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
platforms: linux/amd64

161
.github/workflows/dokploy.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Dokploy Docker Build
on:
push:
branches: [main, canary, "feat/monitoring"]
env:
IMAGE_NAME: dokploy/dokploy
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag and version
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
docker-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag and version
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
combine-manifests:
needs: [docker-amd, docker-arm]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
docker buildx imagetools create -t ${IMAGE_NAME}:${VERSION} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
else
TAG="feature"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
fi
generate-release:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

118
.github/workflows/monitoring.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: Dokploy Monitoring Build
on:
push:
branches: [main, canary]
env:
IMAGE_NAME: dokploy/monitoring
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
docker-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
combine-manifests:
needs: [docker-amd, docker-arm]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
else
TAG="feature"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
fi

3
.gitignore vendored
View File

@@ -39,3 +39,6 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
.db

View File

@@ -1,4 +1,4 @@
FROM node:20-slim AS base
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

View File

@@ -1,4 +1,4 @@
FROM node:20-slim AS base
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

41
Dockerfile.monitoring Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM golang:1.21-alpine3.19 AS builder
# Instalar dependencias necesarias
RUN apk add --no-cache gcc musl-dev sqlite-dev
# Establecer el directorio de trabajo
WORKDIR /app
# Copiar todo el código fuente primero
COPY . .
# Movernos al directorio de la aplicación golang
WORKDIR /app/apps/monitoring
# Descargar dependencias
RUN go mod download
# Compilar la aplicación
RUN CGO_ENABLED=1 GOOS=linux go build -o main main.go
# Etapa final
FROM alpine:3.19
# Instalar SQLite y otras dependencias necesarias
RUN apk add --no-cache sqlite-libs docker-cli
WORKDIR /app
# Copiar el binario compilado y el archivo monitor.go
COPY --from=builder /app/apps/monitoring/main ./main
COPY --from=builder /app/apps/monitoring/main.go ./monitor.go
# COPY --from=builder /app/apps/golang/.env ./.env
# Exponer el puerto
ENV PORT=3001
EXPOSE 3001
# Ejecutar la aplicación
CMD ["./main"]

View File

@@ -1,4 +1,4 @@
FROM node:20-slim AS base
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

View File

@@ -1,4 +1,4 @@
FROM node:20-slim AS base
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

View File

@@ -93,6 +93,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
</div>
### Community Backers 🤝

View File

@@ -0,0 +1,98 @@
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
import { describe, expect, it } from "vitest";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
"x-github-event": "push",
};
const createMockBody = (message: string) => ({
head_commit: {
message,
},
});
const skipKeywords = [
"[skip ci]",
"[ci skip]",
"[no ci]",
"[skip actions]",
"[actions skip]",
];
it("should detect skip keywords in commit message", () => {
for (const keyword of skipKeywords) {
const message = `feat: add new feature ${keyword}`;
const commitMessage = extractCommitMessage(
mockGithubHeaders,
createMockBody(message),
);
expect(commitMessage.includes(keyword)).toBe(true);
}
});
it("should not detect skip keywords in normal commit message", () => {
const message = "feat: add new feature";
const commitMessage = extractCommitMessage(
mockGithubHeaders,
createMockBody(message),
);
for (const keyword of skipKeywords) {
expect(commitMessage.includes(keyword)).toBe(false);
}
});
it("should handle different webhook sources", () => {
// GitHub
expect(
extractCommitMessage(
{ "x-github-event": "push" },
{ head_commit: { message: "[skip ci] test" } },
),
).toBe("[skip ci] test");
// GitLab
expect(
extractCommitMessage(
{ "x-gitlab-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Bitbucket
expect(
extractCommitMessage(
{ "x-event-key": "repo:push" },
{
push: {
changes: [{ new: { target: { message: "[skip ci] test" } } }],
},
},
),
).toBe("[skip ci] test");
// Gitea
expect(
extractCommitMessage(
{ "x-gitea-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
expect(extractCommitMessage(mockGithubHeaders, {})).toBe("NEW COMMIT");
expect(extractCommitMessage({ "x-gitlab-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(
extractCommitMessage(
{ "x-event-key": "repo:push" },
{ push: { changes: [] } },
),
).toBe("NEW COMMIT");
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -45,7 +45,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
project: {
env: "",
adminId: "",
organizationId: "",
name: "",
description: "",
createdAt: "",

View File

@@ -5,7 +5,7 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { Admin, FileConfig } from "@dokploy/server";
import type { FileConfig, User } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
@@ -13,10 +13,34 @@ import {
} from "@dokploy/server";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: Admin = {
createdAt: "",
authId: "",
adminId: "string",
const baseAdmin: User = {
enablePaidFeatures: false,
metricsConfig: {
containers: {
refreshRate: 20,
services: {
include: [],
exclude: [],
},
},
server: {
type: "Dokploy",
cronJob: "",
port: 4500,
refreshRate: 20,
retentionDays: 2,
token: "",
thresholds: {
cpu: 0,
memory: 0,
},
urlCallback: "",
},
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
@@ -27,6 +51,31 @@ const baseAdmin: Admin = {
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
accessedProjects: [],
accessedServices: [],
banExpires: new Date(),
banned: true,
banReason: "",
canAccessToAPI: false,
canCreateProjects: false,
canDeleteProjects: false,
canDeleteServices: false,
canAccessToDocker: false,
canAccessToSSHKeys: false,
canCreateServices: false,
canAccessToTraefikFiles: false,
canAccessToGitProviders: false,
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
token: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {

View File

@@ -26,7 +26,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
project: {
env: "",
adminId: "",
organizationId: "",
name: "",
description: "",
createdAt: "",

View File

@@ -13,6 +13,7 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -144,38 +144,6 @@ export const ShowResources = ({ id, type }: Props) => {
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="268435456 (256MB in bytes)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
@@ -209,6 +177,37 @@ export const ShowResources = ({ id, type }: Props) => {
);
}}
/>
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="268435456 (256MB in bytes)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}

View File

@@ -45,8 +45,8 @@ export const ShowVolumes = ({ id, type }: Props) => {
<div>
<CardTitle className="text-xl">Volumes</CardTitle>
<CardDescription>
If you want to persist data in this postgres database use the
following config to setup the volumes
If you want to persist data in this service use the following config
to setup the volumes
</CardDescription>
</div>
@@ -100,7 +100,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
{mount.type === "file" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground line-clamp-[10] whitespace-break-spaces">
{mount.content}
</span>
</div>
@@ -113,12 +113,21 @@ export const ShowVolumes = ({ id, type }: Props) => {
</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
{mount.type === "file" ? (
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
) : (
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
)}
</div>
<div className="flex flex-row gap-1">
<UpdateVolume

View File

@@ -17,8 +17,15 @@ interface Props {
open: boolean;
onClose: () => void;
serverId?: string;
errorMessage?: string;
}
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
export const ShowDeployment = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
@@ -99,6 +106,8 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
return (
<Dialog
open={open}
@@ -157,9 +166,17 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<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>
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${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>

View File

@@ -8,7 +8,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
@@ -18,8 +18,11 @@ import { ShowDeployment } from "./show-deployment";
interface Props {
applicationId: string;
}
export const ShowDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { data: deployments } = api.deployment.all.useQuery(
{ applicationId },
@@ -100,7 +103,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
<Button
onClick={() => {
setActiveLog(deployment.logPath);
setActiveLog(deployment);
}}
>
View
@@ -112,9 +115,10 @@ export const ShowDeployments = ({ applicationId }: Props) => {
)}
<ShowDeployment
serverId={data?.serverId || ""}
open={activeLog !== null}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</CardContent>
</Card>

View File

@@ -7,7 +7,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import Link from "next/link";
@@ -74,60 +73,66 @@ export const ShowDomains = ({ applicationId }: Props) => {
return (
<div
key={item.domainId}
className="flex w-full items-center gap-4 max-sm:flex-wrap border p-4 rounded-lg"
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
>
<Link
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<ExternalLink className="size-5" />
<span className="truncate max-w-full text-sm">
{item.host}
</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
<Input disabled value={item.host} />
<Button variant="outline" disabled>
{item.path}
</Button>
<Button variant="outline" disabled>
{item.port}
</Button>
<Button variant="outline" disabled>
{item.https ? "HTTPS" : "HTTP"}
</Button>
<div className="flex flex-row gap-1">
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((data) => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</div>
<div className="flex gap-2">
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then(() => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
);

View File

@@ -235,7 +235,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -245,7 +245,12 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -226,7 +226,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -236,7 +236,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -248,7 +248,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{repositories?.map((repo) => {
return (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -260,7 +260,12 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -26,7 +26,9 @@ export const ShowPreviewBuilds = ({
serverId,
trigger,
}: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -77,7 +79,7 @@ export const ShowPreviewBuilds = ({
<Button
onClick={() => {
setActiveLog(deployment.logPath);
setActiveLog(deployment);
}}
>
View
@@ -89,9 +91,10 @@ export const ShowPreviewBuilds = ({
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={activeLog !== null}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);

View File

@@ -20,9 +20,10 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import type { ServiceType } from "@dokploy/server/db/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { Copy, Trash2 } from "lucide-react";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -39,16 +40,42 @@ const deleteComposeSchema = z.object({
type DeleteCompose = z.infer<typeof deleteComposeSchema>;
interface Props {
composeId: string;
id: string;
type: ServiceType | "application";
}
export const DeleteCompose = ({ composeId }: Props) => {
export const DeleteService = ({ id, type }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
const { data } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.remove.useMutation(),
redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(),
application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.remove.useMutation();
const { push } = useRouter();
const form = useForm<DeleteCompose>({
defaultValues: {
@@ -62,14 +89,23 @@ export const DeleteCompose = ({ composeId }: Props) => {
const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) {
const { deleteVolumes } = formData;
await mutateAsync({ composeId, deleteVolumes })
await mutateAsync({
mongoId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
composeId: id || "",
deleteVolumes,
})
.then((result) => {
push(`/dashboard/project/${result?.projectId}`);
toast.success("Compose deleted successfully");
toast.success("deleted successfully");
setIsOpen(false);
})
.catch(() => {
toast.error("Error deleting the compose");
toast.error("Error deleting the service");
});
} else {
form.setError("projectName", {
@@ -95,8 +131,8 @@ export const DeleteCompose = ({ composeId }: Props) => {
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
compose. If you are sure please enter the compose name to delete
this compose.
service. If you are sure please enter the service name to delete
this service.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
@@ -119,9 +155,7 @@ export const DeleteCompose = ({ composeId }: Props) => {
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
copy(`${data.name}/${data.appName}`);
toast.success("Copied to clipboard. Be careful!");
}
}}
@@ -142,27 +176,29 @@ export const DeleteCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="deleteVolumes"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
{type === "compose" && (
<FormField
control={form.control}
name="deleteVolumes"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Delete volumes associated with this compose
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormLabel className="ml-2">
Delete volumes associated with this compose
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</div>

View File

@@ -17,12 +17,14 @@ interface Props {
serverId?: string;
open: boolean;
onClose: () => void;
errorMessage?: string;
}
export const ShowDeploymentCompose = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
const [data, setData] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
@@ -105,6 +107,8 @@ export const ShowDeploymentCompose = ({
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
return (
<Dialog
open={open}
@@ -161,9 +165,17 @@ export const ShowDeploymentCompose = ({
<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>
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${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>

View File

@@ -8,7 +8,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueuesCompose } from "./cancel-queues-compose";
@@ -19,7 +19,9 @@ interface Props {
composeId: string;
}
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.compose.one.useQuery({ composeId });
const { data: deployments } = api.deployment.allByCompose.useQuery(
{ composeId },
@@ -100,7 +102,7 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
<Button
onClick={() => {
setActiveLog(deployment.logPath);
setActiveLog(deployment);
}}
>
View
@@ -112,9 +114,10 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
)}
<ShowDeploymentCompose
serverId={data?.serverId || ""}
open={activeLog !== null}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</CardContent>
</Card>

View File

@@ -7,7 +7,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import Link from "next/link";
@@ -74,59 +73,70 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
return (
<div
key={item.domainId}
className="flex w-full items-center gap-4 max-sm:flex-wrap border p-4 rounded-lg"
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
>
<Link target="_blank" href={`http://${item.host}`}>
<ExternalLink className="size-5" />
</Link>
<Button variant="outline" disabled>
{item.serviceName}
</Button>
<Input disabled value={item.host} />
<Button variant="outline" disabled>
{item.path}
</Button>
<Button variant="outline" disabled>
{item.port}
</Button>
<Button variant="outline" disabled>
{item.https ? "HTTPS" : "HTTP"}
</Button>
<div className="flex flex-row gap-1">
<AddDomainCompose
composeId={composeId}
domainId={item.domainId}
<div className="md:basis-1/2 flex gap-6 w-full items-center">
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
{item.serviceName}
</span>
<Link
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</AddDomainCompose>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((data) => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
<span className="truncate text-sm">{item.host}</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</div>
<div className="flex gap-2">
<AddDomainCompose
composeId={composeId}
domainId={item.domainId}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomainCompose>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((data) => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
);

View File

@@ -14,7 +14,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
import { RandomizeCompose } from "./randomize-compose";
import { ShowUtilities } from "./show-utilities";
interface Props {
composeId: string;
@@ -125,7 +125,7 @@ services:
</Form>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
<RandomizeCompose composeId={composeId} />
<ShowUtilities composeId={composeId} />
</div>
<Button
type="submit"

View File

@@ -237,7 +237,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -247,7 +247,12 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -228,7 +228,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -238,7 +238,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -250,7 +250,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => {
return (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -262,7 +262,12 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -0,0 +1,191 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
composeId: string;
}
const schema = z.object({
isolatedDeployment: z.boolean().optional(),
});
type Schema = z.infer<typeof schema>;
export const IsolatedDeployment = ({ composeId }: Props) => {
const utils = api.useUtils();
const [compose, setCompose] = useState<string>("");
const { mutateAsync, error, isError } =
api.compose.isolatedDeployment.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
console.log(data);
const form = useForm<Schema>({
defaultValues: {
isolatedDeployment: false,
},
resolver: zodResolver(schema),
});
useEffect(() => {
randomizeCompose();
if (data) {
form.reset({
isolatedDeployment: data?.isolatedDeployment || false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (formData: Schema) => {
await updateCompose({
composeId,
isolatedDeployment: formData?.isolatedDeployment || false,
})
.then(async (data) => {
randomizeCompose();
refetch();
toast.success("Compose updated");
})
.catch(() => {
toast.error("Error updating the compose");
});
};
const randomizeCompose = async () => {
await mutateAsync({
composeId,
suffix: data?.appName || "",
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose Isolated");
})
.catch(() => {
toast.error("Error isolating the compose");
});
};
return (
<>
<DialogHeader>
<DialogTitle>Isolate Deployment</DialogTitle>
<DialogDescription>
Use this option to isolate the deployment of this compose file.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This feature creates an isolated environment for your deployment by
adding unique prefixes to all resources. It establishes a dedicated
network based on your compose file's name, ensuring your services run
in isolation. This prevents conflicts when running multiple instances
of the same template or services with identical names.
</span>
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">
Resources that will be isolated:
</h4>
<ul className="list-disc list-inside">
<li>Docker volumes</li>
<li>Docker networks</li>
</ul>
</div>
</div>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="isolatedDeployment"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Enable Randomize ({data?.appName})</FormLabel>
<FormDescription>
Enable randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
</div>
</div>
<div className="flex flex-col gap-4">
<Label>Preview</Label>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</div>
</form>
</Form>
</>
);
};

View File

@@ -1,14 +1,10 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
@@ -20,11 +16,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -70,6 +61,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
const suffix = form.watch("suffix");
useEffect(() => {
randomizeCompose();
if (data) {
form.reset({
suffix: data?.suffix || "",
@@ -110,126 +102,117 @@ export const RandomizeCompose = ({ composeId }: Props) => {
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild onClick={() => randomizeCompose()}>
<Button className="max-lg:w-full" variant="outline">
<Dices className="h-4 w-4" />
Randomize Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you
have conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a suffix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
<AlertBlock type="info">
When you activate this option, we will include a env
`COMPOSE_PREFIX` variable to the compose file so you can use it in
your compose file.
</AlertBlock>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="suffix"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center w-full">
<FormLabel>Suffix</FormLabel>
<FormControl>
<Input
placeholder="Enter a suffix (Optional, example: prod)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="randomize"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Apply Randomize</FormLabel>
<FormDescription>
Apply randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
<Button
type="button"
variant="secondary"
onClick={async () => {
await randomizeCompose();
}}
className="lg:w-fit"
>
Random
</Button>
</div>
<div className="w-full">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you have
conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a suffix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
<AlertBlock type="info">
When you activate this option, we will include a env `COMPOSE_PREFIX`
variable to the compose file so you can use it in your compose file.
</AlertBlock>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="suffix"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center w-full mt-4">
<FormLabel>Suffix</FormLabel>
<FormControl>
<Input
placeholder="Enter a suffix (Optional, example: prod)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</pre>
</form>
</Form>
</DialogContent>
</Dialog>
<FormField
control={form.control}
name="randomize"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Apply Randomize</FormLabel>
<FormDescription>
Apply randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
<Button
type="button"
variant="secondary"
onClick={async () => {
await randomizeCompose();
}}
className="lg:w-fit"
>
Random
</Button>
</div>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</form>
</Form>
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useState } from "react";
import { IsolatedDeployment } from "./isolated-deployment";
import { RandomizeCompose } from "./randomize-compose";
interface Props {
composeId: string;
}
export const ShowUtilities = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">Show Utilities</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Utilities </DialogTitle>
<DialogDescription>Modify the application data</DialogDescription>
</DialogHeader>
<Tabs defaultValue="isolated">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="isolated">Isolated Deployment</TabsTrigger>
<TabsTrigger value="randomize">Randomize Compose</TabsTrigger>
</TabsList>
<TabsContent value="randomize" className="pt-5">
<RandomizeCompose composeId={composeId} />
</TabsContent>
<TabsContent value="isolated" className="pt-5">
<IsolatedDeployment composeId={composeId} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -75,14 +75,14 @@ export const ShowBackups = ({ id, type }: Props) => {
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<DatabaseBackup className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
<span className="text-base text-muted-foreground text-center">
To create a backup it is required to set at least 1 provider.
Please, go to{" "}
<Link
href="/dashboard/settings/server"
href="/dashboard/settings/destinations"
className="text-foreground"
>
Settings
S3 Destinations
</Link>{" "}
to do so.
</span>

View File

@@ -43,7 +43,7 @@ const LOG_STYLES: Record<LogType, LogStyle> = {
export function parseLogs(logString: string): LogLine[] {
// Regex to match the log line format
// Exemple of return :
// Example 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"),
@@ -63,18 +63,10 @@ export function parseLogs(logString: string): LogLine[] {
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,
message: message.trim(),
};
})
.filter((log) => log !== null);

View File

@@ -80,7 +80,7 @@ export const ShowContainers = ({ serverId }: Props) => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">

View File

@@ -35,7 +35,7 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">

View File

@@ -1,314 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
import React, { useEffect, useState } from "react";
import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart";
const defaultData = {
cpu: {
value: 0,
time: "",
},
memory: {
value: {
used: 0,
free: 0,
usedPercentage: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
};
interface Props {
appName: string;
appType?: "application" | "stack" | "docker-compose";
}
export interface DockerStats {
cpu: {
value: number;
time: string;
};
memory: {
value: {
used: number;
free: number;
usedPercentage: number;
total: number;
};
time: string;
};
block: {
value: {
readMb: number;
writeMb: number;
};
time: string;
};
network: {
value: {
inputMb: number;
outputMb: number;
};
time: string;
};
disk: {
value: {
diskTotal: number;
diskUsage: number;
diskUsedPercentage: number;
diskFree: number;
};
time: string;
};
}
export type DockerStatsJSON = {
cpu: DockerStats["cpu"][];
memory: DockerStats["memory"][];
block: DockerStats["block"][];
network: DockerStats["network"][];
disk: DockerStats["disk"][];
};
export const DockerMonitoring = ({
appName,
appType = "application",
}: Props) => {
const { data } = api.application.readAppMonitoring.useQuery(
{ appName },
{
refetchOnWindowFocus: false,
},
);
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
const [currentData, setCurrentData] = useState<DockerStats>(defaultData);
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
}, [appName]);
useEffect(() => {
if (!data) return;
setCurrentData({
cpu: data.cpu[data.cpu.length - 1] ?? currentData.cpu,
memory: data.memory[data.memory.length - 1] ?? currentData.memory,
block: data.block[data.block.length - 1] ?? currentData.block,
network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
});
setAcummulativeData({
block: data?.block || [],
cpu: data?.cpu || [],
disk: data?.disk || [],
memory: data?.memory || [],
network: data?.network || [],
});
}, [data]);
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
const value = JSON.parse(e.data);
if (!value) return;
const data = {
cpu: value.data.cpu ?? currentData.cpu,
memory: value.data.memory ?? currentData.memory,
block: value.data.block ?? currentData.block,
disk: value.data.disk ?? currentData.disk,
network: value.data.network ?? currentData.network,
};
setCurrentData(data);
setAcummulativeData((prevData) => ({
cpu: [...prevData.cpu, data.cpu],
memory: [...prevData.memory, data.memory],
block: [...prevData.block, data.block],
network: [...prevData.network, data.network],
disk: [...prevData.disk, data.disk],
}));
};
ws.onclose = (e) => {
console.log(e.reason);
};
return () => ws.close();
}, [appName]);
return (
<div>
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<header className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Monitoring
</h1>
<p className="text-sm text-muted-foreground">
Watch the usage of your server in the current app
</p>
</div>
</header>
<div className="grid gap-6 md:grid-cols-2">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value.toFixed(2)}%
</span>
<Progress
value={currentData.cpu.value}
className="w-[100%]"
/>
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Memory Usage
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Used: ${(currentData.memory.value.used / 1024 ** 3).toFixed(2)} GB / Limit: ${(currentData.memory.value.total / 1024 ** 3).toFixed(2)} GB`}
</span>
<Progress
value={currentData.memory.value.usedPercentage}
className="w-[100%]"
/>
<DockerMemoryChart
acummulativeData={acummulativeData.memory}
memoryLimitGB={currentData.memory.value.total / 1024 ** 3}
/>
</div>
</CardContent>
</Card>
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Disk Space
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Used: ${currentData.disk.value.diskUsage} GB / Limit: ${currentData.disk.value.diskTotal} GB`}
</span>
<Progress
value={currentData.disk.value.diskUsedPercentage}
className="w-[100%]"
/>
<DockerDiskChart
acummulativeData={acummulativeData.disk}
diskTotal={currentData.disk.value.diskTotal}
/>
</div>
</CardContent>
</Card>
)}
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Block I/O</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb.toFixed(
2,
)} MB / Write: ${currentData.block.value.writeMb.toFixed(
3,
)} MB`}
</span>
<DockerBlockChart acummulativeData={acummulativeData.block} />
</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Network I/O
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb.toFixed(
2,
)} MB / Out MB: ${currentData.network.value.outputMb.toFixed(
2,
)} MB`}
</span>
<DockerNetworkChart
acummulativeData={acummulativeData.network}
/>
</div>
</CardContent>
</Card>
</div>
</div>
</Card>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import {
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["block"];
@@ -90,9 +90,11 @@ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`Read ${payload[0].payload.readMb.toFixed(2)} MB`}</p>
<p>{`Write: ${payload[0].payload.writeMb.toFixed(3)} MB`}</p>
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Read ${payload[0].payload.readMb} `}</p>
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import {
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["cpu"];
@@ -19,7 +19,7 @@ export const DockerCpuChart = ({ acummulativeData }: Props) => {
return {
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toFixed(2),
usage: item.value.toString().split("%")[0],
};
});
return (
@@ -75,7 +75,9 @@ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
</div>
);

View File

@@ -8,7 +8,7 @@ import {
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["disk"];

View File

@@ -8,8 +8,8 @@ import {
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["memory"];
memoryLimitGB: number;
@@ -23,7 +23,8 @@ export const DockerMemoryChart = ({
return {
time: item.time,
name: `Point ${index + 1}`,
usage: (item.value.used / 1024 ** 3).toFixed(2),
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
};
});
return (
@@ -75,10 +76,13 @@ interface CustomTooltipProps {
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
if (active && payload && payload.length && payload[0] && payload[0].payload) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
</div>
);

View File

@@ -8,8 +8,7 @@ import {
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show";
1;
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["network"];
}
@@ -19,8 +18,8 @@ export const DockerNetworkChart = ({ acummulativeData }: Props) => {
return {
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb.toFixed(2),
outMB: item.value.outputMb.toFixed(2),
inMB: item.value.inputMb,
outMB: item.value.outputMb,
};
});
return (
@@ -86,9 +85,11 @@ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`In MB Usage: ${payload[0].payload.inMB} MB`}</p>
<p>{`Out MB Usage: ${payload[0].payload.outMB} MB`}</p>
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { ContainerFreeMonitoring } from "./show-free-container-monitoring";
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ComposeFreeMonitoring = ({
appName,
appType = "stack",
serverId,
}: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName: appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
const [containerAppName, setContainerAppName] = useState<
string | undefined
>();
const [containerId, setContainerId] = useState<string | undefined>();
const { mutateAsync: restart, isLoading: isRestarting } =
api.docker.restartContainer.useMutation();
useEffect(() => {
if (data && data?.length > 0) {
setContainerAppName(data[0]?.name);
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
<>
<CardHeader>
<CardTitle className="text-xl">Monitoring</CardTitle>
<CardDescription>Watch the usage of your compose</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label>Select a container to watch the monitoring</Label>
<div className="flex flex-row gap-4">
<Select
onValueChange={(value) => {
setContainerAppName(value);
setContainerId(
data?.find((container) => container.name === value)
?.containerId,
);
}}
value={containerAppName}
>
<SelectTrigger>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<SelectValue placeholder="Select a container" />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.name}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<Button
isLoading={isRestarting}
onClick={async () => {
if (!containerId) return;
toast.success(`Restarting container ${containerAppName}`);
await restart({ containerId }).then(() => {
toast.success("Container restarted");
});
}}
>
Restart
</Button>
</div>
<ContainerFreeMonitoring
appName={containerAppName || ""}
appType={appType}
/>
</CardContent>
</>
);
};

View File

@@ -0,0 +1,316 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
import React, { useEffect, useState } from "react";
import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart";
const defaultData = {
cpu: {
value: 0,
time: "",
},
memory: {
value: {
used: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
};
interface Props {
appName: string;
appType?: "application" | "stack" | "docker-compose";
}
export interface DockerStats {
cpu: {
value: number;
time: string;
};
memory: {
value: {
used: number;
total: number;
};
time: string;
};
block: {
value: {
readMb: number;
writeMb: number;
};
time: string;
};
network: {
value: {
inputMb: number;
outputMb: number;
};
time: string;
};
disk: {
value: {
diskTotal: number;
diskUsage: number;
diskUsedPercentage: number;
diskFree: number;
};
time: string;
};
}
export type DockerStatsJSON = {
cpu: DockerStats["cpu"][];
memory: DockerStats["memory"][];
block: DockerStats["block"][];
network: DockerStats["network"][];
disk: DockerStats["disk"][];
};
export const convertMemoryToBytes = (
memoryString: string | undefined,
): number => {
if (!memoryString || typeof memoryString !== "string") {
return 0;
}
const value = Number.parseFloat(memoryString) || 0;
const unit = memoryString.replace(/[0-9.]/g, "").trim();
switch (unit) {
case "KiB":
return value * 1024;
case "MiB":
return value * 1024 * 1024;
case "GiB":
return value * 1024 * 1024 * 1024;
case "TiB":
return value * 1024 * 1024 * 1024 * 1024;
default:
return value;
}
};
export const ContainerFreeMonitoring = ({
appName,
appType = "application",
}: Props) => {
const { data } = api.application.readAppMonitoring.useQuery(
{ appName },
{
refetchOnWindowFocus: false,
},
);
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
const [currentData, setCurrentData] = useState<DockerStats>(defaultData);
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
}, [appName]);
useEffect(() => {
if (!data) return;
setCurrentData({
cpu: data.cpu[data.cpu.length - 1] ?? currentData.cpu,
memory: data.memory[data.memory.length - 1] ?? currentData.memory,
block: data.block[data.block.length - 1] ?? currentData.block,
network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
});
setAcummulativeData({
block: data?.block || [],
cpu: data?.cpu || [],
disk: data?.disk || [],
memory: data?.memory || [],
network: data?.network || [],
});
}, [data]);
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
const value = JSON.parse(e.data);
if (!value) return;
const data = {
cpu: value.data.cpu ?? currentData.cpu,
memory: value.data.memory ?? currentData.memory,
block: value.data.block ?? currentData.block,
disk: value.data.disk ?? currentData.disk,
network: value.data.network ?? currentData.network,
};
setCurrentData(data);
setAcummulativeData((prevData) => ({
cpu: [...prevData.cpu, data.cpu],
memory: [...prevData.memory, data.memory],
block: [...prevData.block, data.block],
network: [...prevData.network, data.network],
disk: [...prevData.disk, data.disk],
}));
};
ws.onclose = (e) => {
console.log(e.reason);
};
return () => ws.close();
}, [appName]);
return (
<div className="rounded-xl bg-background shadow-md flex flex-col gap-4">
<header className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1>
<p className="text-sm text-muted-foreground">
Watch the usage of your server in the current app
</p>
</div>
</header>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}%
</span>
<Progress value={currentData.cpu.value} className="w-[100%]" />
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Memory Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Used: ${currentData.memory.value.used} / Limit: ${currentData.memory.value.total} `}
</span>
<Progress
value={
// @ts-ignore
(convertMemoryToBytes(currentData.memory.value.used) /
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total)) *
100
}
className="w-[100%]"
/>
<DockerMemoryChart
acummulativeData={acummulativeData.memory}
memoryLimitGB={
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) /
1024 ** 3
}
/>
</div>
</CardContent>
</Card>
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Space</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Used: ${currentData.disk.value.diskUsage} GB / Limit: ${currentData.disk.value.diskTotal} GB`}
</span>
<Progress
value={currentData.disk.value.diskUsedPercentage}
className="w-[100%]"
/>
<DockerDiskChart
acummulativeData={acummulativeData.disk}
diskTotal={currentData.disk.value.diskTotal}
/>
</div>
</CardContent>
</Card>
)}
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Block I/O</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
</span>
<DockerBlockChart acummulativeData={acummulativeData.block} />
</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Network I/O</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
</span>
<DockerNetworkChart acummulativeData={acummulativeData.network} />
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@@ -0,0 +1,189 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface ContainerMetric {
timestamp: string;
BlockIO: {
read: number;
write: number;
readUnit: string;
writeUnit: string;
};
}
interface Props {
data: ContainerMetric[];
}
interface FormattedMetric {
timestamp: string;
read: number;
write: number;
readUnit: string;
writeUnit: string;
}
const chartConfig = {
read: {
label: "Read",
color: "hsl(217, 91%, 60%)", // Azul brillante
},
write: {
label: "Write",
color: "hsl(142, 71%, 45%)", // Verde brillante
},
} satisfies ChartConfig;
export const ContainerBlockChart = ({ data }: Props) => {
const formattedData = data.map((metric) => ({
timestamp: metric.timestamp,
read: metric.BlockIO.read,
write: metric.BlockIO.write,
readUnit: metric.BlockIO.readUnit,
writeUnit: metric.BlockIO.writeUnit,
}));
const latestData = formattedData[formattedData.length - 1] || {
timestamp: "",
read: 0,
write: 0,
readUnit: "B",
writeUnit: "B",
};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>Block I/O</CardTitle>
<CardDescription>
Read: {latestData.read}
{latestData.readUnit} / Write: {latestData.write}
{latestData.writeUnit}
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={formattedData}>
<defs>
<linearGradient id="fillRead" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(217, 91%, 60%)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="hsl(217, 91%, 60%)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillWrite" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(142, 71%, 45%)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="hsl(142, 71%, 45%)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Read
</span>
<span className="font-bold">
{data.read}
{data.readUnit}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Write
</span>
<span className="font-bold">
{data.write}
{data.writeUnit}
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
name="Write"
dataKey="write"
type="monotone"
fill="url(#fillWrite)"
stroke="hsl(142, 71%, 45%)"
strokeWidth={2}
fillOpacity={0.3}
/>
<Area
name="Read"
dataKey="read"
type="monotone"
fill="url(#fillRead)"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}
fillOpacity={0.3}
/>
<ChartLegend
content={<ChartLegendContent />}
verticalAlign="bottom"
align="center"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,128 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface ContainerMetric {
timestamp: string;
CPU: number;
}
interface Props {
data: ContainerMetric[];
}
const chartConfig = {
cpu: {
label: "CPU",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export const ContainerCPUChart = ({ data }: Props) => {
const formattedData = data.map((metric) => ({
timestamp: metric.timestamp,
cpu: metric.CPU,
}));
const latestData = formattedData[formattedData.length - 1] || {
timestamp: "",
cpu: 0,
};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>CPU</CardTitle>
<CardDescription>CPU Usage: {latestData.cpu}%</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={formattedData}>
<defs>
<linearGradient id="fillCPU" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-1))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-1))"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value}%`} domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
CPU
</span>
<span className="font-bold">{data.cpu}%</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
name="CPU"
dataKey="cpu"
type="monotone"
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
/>
<ChartLegend
content={<ChartLegendContent />}
verticalAlign="bottom"
align="center"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,149 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface ContainerMetric {
timestamp: string;
Memory: {
percentage: number;
used: number;
total: number;
usedUnit: string;
totalUnit: string;
};
}
interface Props {
data: ContainerMetric[];
}
const chartConfig = {
memory: {
label: "Memory",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
const formatMemoryValue = (value: number) => {
return value.toLocaleString("en-US", {
minimumFractionDigits: 1,
maximumFractionDigits: 2,
});
};
export const ContainerMemoryChart = ({ data }: Props) => {
const formattedData = data.map((metric) => ({
timestamp: metric.timestamp,
memory: metric.Memory.percentage,
usage: `${formatMemoryValue(metric.Memory.used)}${metric.Memory.usedUnit} / ${formatMemoryValue(metric.Memory.total)}${metric.Memory.totalUnit}`,
}));
const latestData = formattedData[formattedData.length - 1] || {
timestamp: "",
memory: 0,
usage: "0 / 0 B",
};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>Memory</CardTitle>
<CardDescription>Memory Usage: {latestData.usage}</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={formattedData}>
<defs>
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-2))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-2))"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value}%`} domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Memory
</span>
<span className="font-bold">{data.memory}%</span>
</div>
<div className="flex flex-col col-span-2">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Usage
</span>
<span className="font-bold">{data.usage}</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
name="Memory"
dataKey="memory"
type="monotone"
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
/>
<ChartLegend
content={<ChartLegendContent />}
verticalAlign="bottom"
align="center"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,186 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface ContainerMetric {
timestamp: string;
Network: {
input: number;
output: number;
inputUnit: string;
outputUnit: string;
};
}
interface Props {
data: ContainerMetric[];
}
interface FormattedMetric {
timestamp: string;
input: number;
output: number;
inputUnit: string;
outputUnit: string;
}
const chartConfig = {
input: {
label: "Input",
color: "hsl(var(--chart-3))",
},
output: {
label: "Output",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
export const ContainerNetworkChart = ({ data }: Props) => {
const formattedData: FormattedMetric[] = data.map((metric) => ({
timestamp: metric.timestamp,
input: metric.Network.input,
output: metric.Network.output,
inputUnit: metric.Network.inputUnit,
outputUnit: metric.Network.outputUnit,
}));
const latestData = formattedData[formattedData.length - 1] || {
input: 0,
output: 0,
inputUnit: "B",
outputUnit: "B",
};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>Network I/O</CardTitle>
<CardDescription>
Input: {latestData.input}
{latestData.inputUnit} / Output: {latestData.output}
{latestData.outputUnit}
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={formattedData}>
<defs>
<linearGradient id="fillInput" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-3))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-3))"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillOutput" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-4))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-4))"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Input
</span>
<span className="font-bold">
{data.input}
{data.inputUnit}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Output
</span>
<span className="font-bold">
{data.output}
{data.outputUnit}
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
name="Input"
dataKey="input"
type="monotone"
fill="url(#fillInput)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
/>
<Area
name="Output"
dataKey="output"
type="monotone"
fill="url(#fillOutput)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
/>
<ChartLegend
content={<ChartLegendContent />}
verticalAlign="bottom"
align="center"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@@ -20,18 +20,22 @@ import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DockerMonitoring } from "../../monitoring/docker/show";
import { ContainerPaidMonitoring } from "./show-paid-container-monitoring";
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
baseUrl: string;
token: string;
}
export const ShowMonitoringCompose = ({
export const ComposePaidMonitoring = ({
appName,
appType = "stack",
serverId,
baseUrl,
token,
}: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -44,9 +48,9 @@ export const ShowMonitoringCompose = ({
},
);
const [containerAppName, setContainerAppName] = useState<
string | undefined
>();
const [containerAppName, setContainerAppName] = useState<string | undefined>(
"",
);
const [containerId, setContainerId] = useState<string | undefined>();
@@ -62,7 +66,7 @@ export const ShowMonitoringCompose = ({
return (
<div>
<Card className="bg-background">
<Card className="bg-background border-0">
<CardHeader>
<CardTitle className="text-xl">Monitoring</CardTitle>
<CardDescription>Watch the usage of your compose</CardDescription>
@@ -118,10 +122,13 @@ export const ShowMonitoringCompose = ({
Restart
</Button>
</div>
<DockerMonitoring
appName={containerAppName || ""}
appType={appType}
/>
<div className="flex flex-col gap-4">
<ContainerPaidMonitoring
appName={containerAppName || ""}
baseUrl={baseUrl}
token={token}
/>
</div>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,258 @@
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { Cpu, HardDrive, Loader2, MemoryStick, Network } from "lucide-react";
import { useEffect, useState } from "react";
import { ContainerBlockChart } from "./container-block-chart";
import { ContainerCPUChart } from "./container-cpu-chart";
import { ContainerMemoryChart } from "./container-memory-chart";
import { ContainerNetworkChart } from "./container-network-chart";
const REFRESH_INTERVALS = {
"5000": "5 Seconds",
"10000": "10 Seconds",
"20000": "20 Seconds",
"30000": "30 Seconds",
} as const;
const DATA_POINTS_OPTIONS = {
"50": "50 points",
"200": "200 points",
"500": "500 points",
"800": "800 points",
"1200": "1200 points",
"1600": "1600 points",
"2000": "2000 points",
all: "All points",
} as const;
interface ContainerMetric {
timestamp: string;
CPU: number;
Memory: {
percentage: number;
used: number;
total: number;
unit: string;
usedUnit: string;
totalUnit: string;
};
Network: {
input: number;
output: number;
inputUnit: string;
outputUnit: string;
};
BlockIO: {
read: number;
write: number;
readUnit: string;
writeUnit: string;
};
Container: string;
ID: string;
Name: string;
}
interface Props {
appName: string;
baseUrl: string;
token: string;
}
export const ContainerPaidMonitoring = ({ appName, baseUrl, token }: Props) => {
const [historicalData, setHistoricalData] = useState<ContainerMetric[]>([]);
const [metrics, setMetrics] = useState<ContainerMetric>(
{} as ContainerMetric,
);
const [dataPoints, setDataPoints] =
useState<keyof typeof DATA_POINTS_OPTIONS>("50");
const [refreshInterval, setRefreshInterval] = useState<string>("5000");
const {
data,
isLoading,
error: queryError,
} = api.user.getContainerMetrics.useQuery(
{
url: baseUrl,
token,
dataPoints,
appName,
},
{
refetchInterval:
dataPoints === "all" ? undefined : Number.parseInt(refreshInterval),
enabled: !!appName,
},
);
useEffect(() => {
if (!data) return;
// @ts-ignore
setHistoricalData(data);
// @ts-ignore
setMetrics(data[data.length - 1]);
}, [data]);
if (isLoading) {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (queryError) {
return (
<div className="mt-5 flex min-h-[55vh] w-full items-center justify-center p-4">
<div className="max-w-xl text-center">
<p className="mb-2 text-base font-medium leading-none text-muted-foreground">
Error fetching metrics for{" "}
<strong className="text-primary">{appName}</strong>
</p>
<p className="whitespace-pre-line text-sm text-destructive">
{queryError instanceof Error
? queryError.message
: "Failed to fetch metrics, Please check your monitoring Instance is Configured correctly."}
</p>
<p className=" text-sm text-muted-foreground">URL: {baseUrl}</p>
</div>
</div>
);
}
return (
<>
<div className="flex items-center justify-between flex-wrap gap-2">
<h2 className="text-2xl font-bold tracking-tight">
Container Monitoring
</h2>
<div className="flex items-center gap-4 flex-wrap">
<div>
<span className="text-sm text-muted-foreground">Data points:</span>
<Select
value={dataPoints}
onValueChange={(value: keyof typeof DATA_POINTS_OPTIONS) =>
setDataPoints(value)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select points" />
</SelectTrigger>
<SelectContent>
{Object.entries(DATA_POINTS_OPTIONS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<span className="text-sm text-muted-foreground">
Refresh interval:
</span>
<Select
value={refreshInterval}
onValueChange={(value: keyof typeof REFRESH_INTERVALS) =>
setRefreshInterval(value)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
{Object.entries(REFRESH_INTERVALS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
<Card className="p-6 bg-transparent">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">CPU Usage</h3>
</div>
<p className="mt-2 text-2xl font-bold">{metrics.CPU}%</p>
</Card>
<Card className="p-6 bg-transparent">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Memory Usage</h3>
</div>
<p className="mt-2 text-2xl font-bold">
{metrics?.Memory?.percentage}%
</p>
<p className="mt-1 text-sm text-muted-foreground">
{metrics?.Memory?.used} {metrics?.Memory?.unit} /{" "}
{metrics?.Memory?.total} {metrics?.Memory?.unit}
</p>
</Card>
<Card className="p-6 bg-transparent">
<div className="flex items-center gap-2">
<Network className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Network I/O</h3>
</div>
<p className="mt-2 text-2xl font-bold">
{metrics?.Network?.input} {metrics?.Network?.inputUnit} /{" "}
{metrics?.Network?.output} {metrics?.Network?.outputUnit}
</p>
</Card>
<Card className="p-6 bg-transparent">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Block I/O</h3>
</div>
<p className="mt-2 text-2xl font-bold">
{metrics?.BlockIO?.read} {metrics?.BlockIO?.readUnit} /{" "}
{metrics?.BlockIO?.write} {metrics?.BlockIO?.writeUnit}
</p>
</Card>
</div>
{/* Container Information */}
<Card className="p-6 bg-transparent">
<h3 className="text-lg font-medium mb-4">Container Information</h3>
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="text-sm font-medium text-muted-foreground">
Container ID
</h4>
<p className="mt-1">{metrics.ID}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">Name</h4>
<p className="mt-1 truncate">{metrics.Name}</p>
</div>
</div>
</Card>
{/* Charts Grid */}
<div className="grid gap-4 grid-cols-1 md:grid-cols-1 xl:grid-cols-2">
<ContainerCPUChart data={historicalData} />
<ContainerMemoryChart data={historicalData} />
<ContainerBlockChart data={historicalData} />
<ContainerNetworkChart data={historicalData} />
</div>
</>
);
};

View File

@@ -0,0 +1,115 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface CPUChartProps {
data: any[];
}
const chartConfig = {
cpu: {
label: "CPU",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export function CPUChart({ data }: CPUChartProps) {
const latestData = data[data.length - 1] || {};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>CPU</CardTitle>
<CardDescription>CPU Usage: {latestData.cpu}%</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={data}>
<defs>
<linearGradient id="fillCPU" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-1))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-1))"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value}%`} domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
CPU
</span>
<span className="font-bold">{data.cpu}%</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
name="CPU"
dataKey="cpu"
type="monotone"
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
/>
<ChartLegend
content={<ChartLegendContent />}
verticalAlign="bottom"
align="center"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,120 @@
import { HardDrive } from "lucide-react";
import {
Label,
PolarGrid,
PolarRadiusAxis,
RadialBar,
RadialBarChart,
} from "recharts";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
interface RadialChartProps {
data: any;
}
export function DiskChart({ data }: RadialChartProps) {
const diskUsed = Number.parseFloat(data.diskUsed || 0);
const totalDiskGB = Number.parseFloat(data.totalDisk || 0);
const usedDiskGB = (totalDiskGB * diskUsed) / 100;
const chartData = [
{
disk: 25,
fill: "hsl(var(--chart-2))",
},
];
const chartConfig = {
disk: {
label: "Disk",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
const endAngle = (diskUsed * 360) / 100;
return (
<Card className="flex flex-col bg-transparent">
<CardHeader className="items-center border-b pb-5">
<CardTitle>Disk</CardTitle>
<CardDescription>Storage Space</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RadialBarChart
data={chartData}
startAngle={0}
endAngle={endAngle}
innerRadius={80}
outerRadius={110}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-background"
polarRadius={[86, 74]}
/>
<RadialBar
dataKey="disk"
background
cornerRadius={10}
fill="hsl(var(--chart-2))"
/>
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-4xl font-bold"
>
{diskUsed.toFixed(1)}%
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground text-sm"
>
Used
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
<HardDrive className="h-4 w-4" /> {usedDiskGB.toFixed(1)} GB used
</div>
<div className="leading-none text-muted-foreground">
Of {totalDiskGB.toFixed(1)} GB total
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,128 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface MemoryChartProps {
data: any[];
}
const chartConfig = {
Memory: {
label: "Memory",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export function MemoryChart({ data }: MemoryChartProps) {
const latestData = data[data.length - 1] || {};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>Memory</CardTitle>
<CardDescription>
Memory Usage: {latestData.memUsedGB} GB of {latestData.memTotal} GB (
{latestData.memUsed}%)
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={data}>
<defs>
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-2))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-2))"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis
yAxisId="left"
orientation="left"
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<YAxis
yAxisId="right"
orientation="right"
tickFormatter={(value) => `${value.toFixed(1)} GB`}
domain={[
0,
Math.ceil(Number.parseFloat(latestData.memTotal || "0")),
]}
/>
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Memory
</span>
<span className="font-bold">
{data.memUsed}% ({data.memUsedGB} GB)
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
yAxisId="left"
dataKey="memUsed"
type="monotone"
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
name="Memory"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import { formatTimestamp } from "@/lib/utils";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
interface NetworkChartProps {
data: any[];
}
const chartConfig = {
networkIn: {
label: "Network In",
color: "hsl(var(--chart-3))",
},
networkOut: {
label: "Network Out",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
export function NetworkChart({ data }: NetworkChartProps) {
const latestData = data[data.length - 1] || {};
return (
<Card className="bg-transparent">
<CardHeader className="border-b py-5">
<CardTitle>Network</CardTitle>
<CardDescription>
Network Traffic: {latestData.networkOut} KB/s {" "}
{latestData.networkIn} KB/s
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={data}>
<defs>
<linearGradient id="fillNetworkIn" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-3))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-3))"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillNetworkOut" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--chart-4))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--chart-4))"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value} KB/s`} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload?.[0]?.payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Time
</span>
<span className="font-bold">
{formatTimestamp(label)}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Network
</span>
<span className="font-bold">
{data.networkOut} KB/s
<br /> {data.networkIn} KB/s
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Area
name="Network In"
dataKey="networkIn"
type="monotone"
fill="url(#fillNetworkIn)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
/>
<Area
name="Network Out"
dataKey="networkOut"
type="monotone"
fill="url(#fillNetworkOut)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
/>
<ChartLegend
content={<ChartLegendContent />}
verticalAlign="bottom"
align="center"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,276 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { Clock, Cpu, HardDrive, Loader2, MemoryStick } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { CPUChart } from "./cpu-chart";
import { DiskChart } from "./disk-chart";
import { MemoryChart } from "./memory-chart";
import { NetworkChart } from "./network-chart";
const REFRESH_INTERVALS = {
"5000": "5 Seconds",
"10000": "10 Seconds",
"20000": "20 Seconds",
"30000": "30 Seconds",
} as const;
const DATA_POINTS_OPTIONS = {
"50": "50 points",
"200": "200 points",
"500": "500 points",
"800": "800 points",
"1200": "1200 points",
"1600": "1600 points",
"2000": "2000 points",
all: "All points",
} as const;
interface SystemMetrics {
cpu: string;
cpuModel: string;
cpuCores: number;
cpuPhysicalCores: number;
cpuSpeed: number;
os: string;
distro: string;
kernel: string;
arch: string;
memUsed: string;
memUsedGB: string;
memTotal: string;
uptime: number;
diskUsed: string;
totalDisk: string;
networkIn: string;
networkOut: string;
timestamp: string;
}
interface Props {
BASE_URL?: string;
token?: string;
}
export const ShowPaidMonitoring = ({
BASE_URL = process.env.NEXT_PUBLIC_METRICS_URL ||
"http://localhost:3001/metrics",
token = process.env.NEXT_PUBLIC_METRICS_TOKEN || "my-token",
}: Props) => {
const [historicalData, setHistoricalData] = useState<SystemMetrics[]>([]);
const [metrics, setMetrics] = useState<SystemMetrics>({} as SystemMetrics);
const [dataPoints, setDataPoints] =
useState<keyof typeof DATA_POINTS_OPTIONS>("50");
const [refreshInterval, setRefreshInterval] = useState<string>("5000");
const {
data,
isLoading,
error: queryError,
} = api.user.getServerMetrics.useQuery(
{
url: BASE_URL,
token,
dataPoints,
},
{
refetchInterval:
dataPoints === "all" ? undefined : Number.parseInt(refreshInterval),
enabled: true,
},
);
useEffect(() => {
if (!data) return;
const formattedData = data.map((metric: SystemMetrics) => ({
timestamp: metric.timestamp,
cpu: Number.parseFloat(metric.cpu),
cpuModel: metric.cpuModel,
cpuCores: metric.cpuCores,
cpuPhysicalCores: metric.cpuPhysicalCores,
cpuSpeed: metric.cpuSpeed,
os: metric.os,
distro: metric.distro,
kernel: metric.kernel,
arch: metric.arch,
memUsed: Number.parseFloat(metric.memUsed),
memUsedGB: Number.parseFloat(metric.memUsedGB),
memTotal: Number.parseFloat(metric.memTotal),
networkIn: Number.parseFloat(metric.networkIn),
networkOut: Number.parseFloat(metric.networkOut),
diskUsed: Number.parseFloat(metric.diskUsed),
totalDisk: Number.parseFloat(metric.totalDisk),
uptime: metric.uptime,
}));
// @ts-ignore
setHistoricalData(formattedData);
// @ts-ignore
setMetrics(formattedData[formattedData.length - 1] || {});
}, [data]);
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / (24 * 60 * 60));
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
const minutes = Math.floor((seconds % (60 * 60)) / 60);
return `${days}d ${hours}h ${minutes}m`;
};
if (isLoading) {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (queryError) {
return (
<div className="flex min-h-[55vh] w-full items-center justify-center p-4">
<div className="max-w-xl text-center">
<p className="mb-2 text-base font-medium leading-none text-muted-foreground">
Error fetching metrics{" "}
</p>
<p className="whitespace-pre-line text-sm text-destructive">
{queryError instanceof Error
? queryError.message
: "Failed to fetch metrics, Please check your monitoring Instance is Configured correctly."}
</p>
<p className=" text-sm text-muted-foreground">URL: {BASE_URL}</p>
</div>
</div>
);
}
return (
<div className="space-y-4 pt-5 pb-10 w-full md:px-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h2 className="text-2xl font-bold tracking-tight">System Monitoring</h2>
<div className="flex items-center gap-4 flex-wrap">
<div>
<span className="text-sm text-muted-foreground">Data points:</span>
<Select
value={dataPoints}
onValueChange={(value: keyof typeof DATA_POINTS_OPTIONS) =>
setDataPoints(value)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select points" />
</SelectTrigger>
<SelectContent>
{Object.entries(DATA_POINTS_OPTIONS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<span className="text-sm text-muted-foreground">
Refresh interval:
</span>
<Select
value={refreshInterval}
onValueChange={(value: keyof typeof REFRESH_INTERVALS) =>
setRefreshInterval(value)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
{Object.entries(REFRESH_INTERVALS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Uptime</h3>
</div>
<p className="mt-2 text-2xl font-bold">
{formatUptime(metrics.uptime || 0)}
</p>
</div>
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">CPU Usage</h3>
</div>
<p className="mt-2 text-2xl font-bold">{metrics.cpu}%</p>
</div>
<div className="rounded-lg border text-card-foreground bg-transparent shadow-sm p-6">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Memory Usage</h3>
</div>
<p className="mt-2 text-2xl font-bold">
{metrics.memUsedGB} GB / {metrics.memTotal} GB
</p>
</div>
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Disk Usage</h3>
</div>
<p className="mt-2 text-2xl font-bold">{metrics.diskUsed}%</p>
</div>
</div>
{/* System Information */}
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
<h3 className="text-lg font-medium mb-4">System Information</h3>
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="text-sm font-medium text-muted-foreground">CPU</h4>
<p className="mt-1">{metrics.cpuModel}</p>
<p className="text-sm text-muted-foreground mt-1">
{metrics.cpuPhysicalCores} Physical Cores ({metrics.cpuCores}{" "}
Threads) @ {metrics.cpuSpeed}GHz
</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">
Operating System
</h4>
<p className="mt-1">{metrics.distro}</p>
<p className="text-sm text-muted-foreground mt-1">
Kernel: {metrics.kernel} ({metrics.arch})
</p>
</div>
</div>
</div>
{/* Charts Grid */}
<div className="grid gap-4 grid-cols-1 md:grid-cols-1 xl:grid-cols-2">
<CPUChart data={historicalData} />
<MemoryChart data={historicalData} />
<DiskChart data={metrics} />
<NetworkChart data={historicalData} />
</div>
</div>
);
};

View File

@@ -1,10 +0,0 @@
import React from "react";
import { DockerMonitoring } from "../docker/show";
export const ShowMonitoring = () => {
return (
<div className="my-6 w-full ">
<DockerMonitoring appName="dokploy" />
</div>
);
};

View File

@@ -0,0 +1,119 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { PenBoxIcon, Plus, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface Props {
organizationId?: string;
children?: React.ReactNode;
}
export function AddOrganization({ organizationId, children }: Props) {
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
organizationId: organizationId ?? "",
},
{
enabled: !!organizationId,
},
);
const { mutateAsync, isLoading } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
useEffect(() => {
if (organization) {
setName(organization.name);
}
}, [organization]);
const handleSubmit = async () => {
await mutateAsync({ name, organizationId: organizationId ?? "" })
.then(() => {
setOpen(false);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
utils.organization.all.invalidate();
})
.catch((error) => {
console.error(error);
toast.error(
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onClick={() => {
setOpen(true);
}}
onSelect={(e) => e.preventDefault()}
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{organizationId ? "Update organization" : "Add organization"}
</DialogTitle>
<DialogDescription>
{organizationId
? "Update the organization name"
: "Create a new organization to manage your projects."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleSubmit} isLoading={isLoading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -70,7 +70,7 @@ interface Props {
export const AddApplication = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: servers } = api.server.withSSHKey.useQuery();
@@ -166,7 +166,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server (Optional)
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -197,7 +197,12 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
key={server.serverId}
value={server.serverId}
>
{server.name}
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>

View File

@@ -73,6 +73,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
@@ -173,7 +174,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server (Optional)
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -204,7 +205,12 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
key={server.serverId}
value={server.serverId}
>
{server.name}
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>

View File

@@ -89,7 +89,7 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("postgres"),
databaseName: z.string().min(1, "Database name required"),
databaseName: z.string().default("postgres"),
databaseUser: z.string().default("postgres"),
})
.merge(baseDatabaseSchema),
@@ -110,7 +110,7 @@ const mySchema = z.discriminatedUnion("type", [
type: z.literal("mysql"),
databaseRootPassword: z.string().default(""),
databaseUser: z.string().default("mysql"),
databaseName: z.string().min(1, "Database name required"),
databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
@@ -119,7 +119,7 @@ const mySchema = z.discriminatedUnion("type", [
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z.string().default(""),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().min(1, "Database name required"),
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
]);
@@ -206,7 +206,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
promise = postgresMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName,
databaseName: data.databaseName || "postgres",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
@@ -233,7 +233,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
...commonParams,
databasePassword: data.databasePassword,
databaseRootPassword: data.databaseRootPassword,
databaseName: data.databaseName,
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
@@ -242,7 +242,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
promise = mysqlMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName,
databaseName: data.databaseName || "mysql",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
databaseRootPassword: data.databaseRootPassword,

View File

@@ -80,6 +80,7 @@ export const AddTemplate = ({ projectId }: Props) => {
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const { data } = api.compose.templates.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: tags, isLoading: isLoadingTags } =
api.compose.getTags.useQuery();
@@ -114,26 +115,28 @@ export const AddTemplate = ({ projectId }: Props) => {
</DialogTrigger>
<DialogContent className="max-h-screen sm:max-w-[90vw] p-0">
<DialogHeader className="sticky top-0 z-10 bg-background p-6 border-b">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
<div>
<DialogTitle>Create from Template</DialogTitle>
<DialogDescription>
Create an open source application from a template
</DialogDescription>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
className="w-[200px]"
className="w-full sm:w-[200px]"
value={query}
/>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn("w-[200px] justify-between !bg-input")}
className={cn(
"w-full sm:w-[200px] justify-between !bg-input",
)}
>
{isLoadingTags
? "Loading...."
@@ -224,7 +227,11 @@ export const AddTemplate = ({ projectId }: Props) => {
<ScrollArea className="h-[calc(98vh-8rem)]">
<div className="p-6">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isError && (
<AlertBlock type="error" className="mb-4">
{error?.message}
</AlertBlock>
)}
{templates.length === 0 ? (
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
@@ -238,8 +245,8 @@ export const AddTemplate = ({ projectId }: Props) => {
className={cn(
"grid gap-6",
viewMode === "detailed"
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-6"
: "grid-cols-2 sm:grid-cols-4 lg:grid-cols-8",
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
)}
>
{templates?.map((template, index) => (
@@ -302,7 +309,7 @@ export const AddTemplate = ({ projectId }: Props) => {
{/* Create Button */}
<div
className={cn(
"flex-none px-6 pb-6 pt-3 mt-auto",
"flex-none px-6 py-3 mt-auto",
viewMode === "detailed"
? "flex items-center justify-between bg-muted/30 border-t"
: "flex justify-center",
@@ -340,6 +347,7 @@ export const AddTemplate = ({ projectId }: Props) => {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
size="sm"
className={cn(
"w-auto",
@@ -365,7 +373,8 @@ export const AddTemplate = ({ projectId }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<Label className="break-all w-fit flex flex-row gap-1 items-center pb-2 pt-3.5">
Select a Server (Optional)
Select a Server{" "}
{!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
@@ -398,7 +407,12 @@ export const AddTemplate = ({ projectId }: Props) => {
key={server.serverId}
value={server.serverId}
>
{server.name}
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>

View File

@@ -21,6 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, SquarePen } from "lucide-react";
@@ -97,6 +98,18 @@ export const HandleProject = ({ projectId }: Props) => {
);
});
};
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -118,7 +131,7 @@ export const HandleProject = ({ projectId }: Props) => {
</DialogTrigger>
<DialogContent className="sm:m:max-w-lg ">
<DialogHeader>
<DialogTitle>Add a project</DialogTitle>
<DialogTitle>{projectId ? "Update" : "Add a"} project</DialogTitle>
<DialogDescription>The home of something big!</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

View File

@@ -99,8 +99,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
<DialogTitle>Project Environment</DialogTitle>
<DialogDescription>
Update the env Environment variables that are accessible to all
services of this project. Use this syntax to reference project-level
variables in your service environments:
services of this project.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

View File

@@ -1,5 +1,6 @@
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
AlertDialogAction,
@@ -23,8 +24,10 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@@ -48,15 +51,7 @@ import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data, isLoading } = 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 { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState("");
@@ -75,10 +70,10 @@ export const ShowProjects = () => {
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<div className="rounded-xl bg-background shadow-md ">
<div className="flex justify-between gap-4 w-full items-center">
<CardHeader className="">
<div className="flex justify-between gap-4 w-full items-center flex-wrap p-6">
<CardHeader className="p-0">
<CardTitle className="text-xl flex flex-row gap-2">
<FolderInput className="size-6 text-muted-foreground self-center" />
Projects
@@ -87,9 +82,12 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
<div className=" px-4 ">
<HandleProject />
</div>
{(auth?.role === "owner" || auth?.user?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
)}
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
@@ -146,14 +144,97 @@ export const ShowProjects = () => {
href={`/dashboard/project/${project.projectId}`}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
<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>
{project.applications.length > 0 ||
project.compose.length > 0 ? (
<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 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}
>
{project.applications.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Applications
</DropdownMenuLabel>
{project.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={app.applicationStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.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>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
</DropdownMenuGroup>
)}
{project.compose.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.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>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
@@ -179,7 +260,10 @@ export const ShowProjects = () => {
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuContent
className="w-[200px] space-y-2 overflow-y-auto max-h-[280px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuLabel className="font-normal">
Actions
</DropdownMenuLabel>
@@ -201,8 +285,8 @@ export const ShowProjects = () => {
<div
onClick={(e) => e.stopPropagation()}
>
{(auth?.rol === "admin" ||
user?.canDeleteProjects) && (
{(auth?.role === "owner" ||
auth?.user?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem

View File

@@ -168,6 +168,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>

View File

@@ -18,6 +18,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import {
type Services,
extractServices,
@@ -35,8 +36,10 @@ 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: session } = authClient.useSession();
const { data } = api.project.all.useQuery(undefined, {
enabled: !!session,
});
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => {

View File

@@ -39,7 +39,7 @@ export const calculatePrice = (count: number, isAnnual = false) => {
};
export const ShowBilling = () => {
const { data: servers } = api.server.all.useQuery(undefined);
const { data: admin } = api.admin.one.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
@@ -70,7 +70,7 @@ export const ShowBilling = () => {
return isAnnual ? interval === "year" : interval === "month";
});
const maxServers = admin?.serversQuantity ?? 1;
const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers?.length ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100);
@@ -98,17 +98,17 @@ export const ShowBilling = () => {
<TabsTrigger value="annual">Annual</TabsTrigger>
</TabsList>
</Tabs>
{admin?.stripeSubscriptionId && (
{admin?.user.stripeSubscriptionId && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
<p className="text-sm text-muted-foreground">
You have {servers?.length} server on your plan of{" "}
{admin?.serversQuantity} servers
{admin?.user.serversQuantity} servers
</p>
<div>
<Progress value={safePercentage} className="max-w-lg" />
</div>
{admin && admin.serversQuantity! <= servers?.length! && (
{admin && admin.user.serversQuantity! <= servers?.length! && (
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
@@ -279,7 +279,7 @@ export const ShowBilling = () => {
"flex flex-row items-center gap-2 mt-4",
)}
>
{admin?.stripeCustomerId && (
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"

View File

@@ -10,12 +10,12 @@ import type React from "react";
import { useEffect, useState } from "react";
export const ShowWelcomeDokploy = () => {
const { data } = api.auth.get.useQuery();
const { data } = api.user.get.useQuery();
const [open, setOpen] = useState(false);
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
if (!isCloud || data?.rol !== "admin") {
if (!isCloud || data?.role !== "admin") {
return null;
}
@@ -24,14 +24,14 @@ export const ShowWelcomeDokploy = () => {
!isLoading &&
isCloud &&
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
data?.rol === "admin"
data?.role === "owner"
) {
setOpen(true);
}
}, [isCloud, isLoading]);
const handleClose = (isOpen: boolean) => {
if (data?.rol === "admin") {
if (data?.role === "owner") {
setOpen(isOpen);
if (!isOpen) {
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal

View File

@@ -61,6 +61,7 @@ export const AddCertificate = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, isError, error, isLoading } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
@@ -85,6 +86,7 @@ export const AddCertificate = () => {
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId,
organizationId: "",
})
.then(async () => {
toast.success("Certificate Created");
@@ -181,7 +183,7 @@ export const AddCertificate = () => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server (Optional)
Select a Server {!isCloud && "(Optional)"}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -202,7 +204,12 @@ export const AddCertificate = () => {
key={server.serverId}
value={server.serverId}
>
{server.name}
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>

View File

@@ -50,7 +50,7 @@ export const ShowCertificates = () => {
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
<span className="text-base text-muted-foreground text-center">
You don't have any certificates created
</span>
<AddCertificate />

View File

@@ -53,7 +53,7 @@ export const AddBitbucketProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.auth.get.useQuery();
const { data: auth } = api.user.get.useQuery();
const router = useRouter();
const form = useForm<Schema>({
defaultValues: {

View File

@@ -10,13 +10,15 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { format } from "date-fns";
import { useEffect, useState } from "react";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const { data } = api.auth.get.useQuery();
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
const [organizationName, setOrganization] = useState("");
@@ -25,7 +27,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?authId=${data?.id}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`,
url: origin,
hook_attributes: {
@@ -93,8 +95,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${data?.id}`
: `https://github.com/settings/apps/new?state=gh_init:${data?.id}`
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
}
method="post"
>

View File

@@ -55,7 +55,7 @@ export const AddGitlabProvider = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const url = useUrl();
const { data: auth } = api.auth.get.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync, error, isError } = api.gitlab.create.useMutation();
const webhookUrl = `${url}/api/providers/gitlab/callback`;

View File

@@ -28,7 +28,13 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import {
AlertTriangle,
Mail,
MessageCircleMore,
PenBoxIcon,
PlusIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -43,6 +49,7 @@ const notificationBaseSchema = z.object({
databaseBackup: z.boolean().default(false),
dokployRestart: z.boolean().default(false),
dockerCleanup: z.boolean().default(false),
serverThreshold: z.boolean().default(false),
});
export const notificationSchema = z.discriminatedUnion("type", [
@@ -84,6 +91,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("gotify"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
appToken: z.string().min(1, { message: "App Token is required" }),
priority: z.number().min(1).max(10).default(5),
decoration: z.boolean().default(true),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -103,6 +119,10 @@ export const notificationsMap = {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
gotify: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify",
},
};
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -126,13 +146,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
);
const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
api.notification.testSlackConnection.useMutation();
const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
api.notification.testTelegramConnection.useMutation();
const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -145,6 +166,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -181,6 +205,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
channel: notification.slack?.channel || "",
name: notification.name,
type: notification.notificationType,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "telegram") {
form.reset({
@@ -193,6 +218,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
type: notification.notificationType,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "discord") {
form.reset({
@@ -205,6 +231,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
decoration: notification.discord?.decoration || undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "email") {
form.reset({
@@ -221,6 +248,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
fromAddress: notification.email?.fromAddress,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "gotify") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
}
} else {
@@ -233,6 +275,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
gotify: gotifyMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -242,6 +285,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart,
databaseBackup,
dockerCleanup,
serverThreshold,
} = data;
let promise: Promise<unknown> | null = null;
if (data.type === "slack") {
@@ -256,6 +300,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: dockerCleanup,
slackId: notification?.slackId || "",
notificationId: notificationId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "telegram") {
promise = telegramMutation.mutateAsync({
@@ -269,6 +314,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
telegramId: notification?.telegramId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "discord") {
promise = discordMutation.mutateAsync({
@@ -282,6 +328,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
discordId: notification?.discordId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "email") {
promise = emailMutation.mutateAsync({
@@ -299,6 +346,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
emailId: notification?.emailId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "gotify") {
promise = gotifyMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
decoration: data.decoration,
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
}
@@ -700,6 +763,94 @@ export const HandleNotifications = ({ notificationId }: Props) => {
</Button>
</>
)}
{type === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input
placeholder="https://gotify.example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appToken"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={5}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="5"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port < 10) {
field.onChange(port);
}
}
}}
type="number"
/>
</FormControl>
<FormDescription>
Message priority (1-10, default: 5)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="decoration"
defaultValue={true}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -814,6 +965,30 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)}
/>
)}
{isCloud && (
<FormField
control={form.control}
name="serverThreshold"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Server Threshold</FormLabel>
<FormDescription>
Trigger the action when the server threshold is
reached.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</div>
</form>
@@ -824,7 +999,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingSlack ||
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail
isLoadingEmail ||
isLoadingGotify
}
variant="secondary"
onClick={async () => {
@@ -853,6 +1029,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"),
});
} else if (type === "gotify") {
await testGotifyConnection({
serverUrl: form.getValues("serverUrl"),
appToken: form.getValues("appToken"),
priority: form.getValues("priority"),
decoration: form.getValues("decoration"),
});
}
toast.success("Connection Success");
} catch (err) {

View File

@@ -13,7 +13,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleNotifications } from "./handle-notifications";
@@ -47,7 +47,7 @@ export const ShowNotifications = () => {
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Bell />
<span className="text-base text-muted-foreground">
<span className="text-base text-muted-foreground text-center">
To send notifications it is required to set at least 1
provider.
</span>
@@ -83,6 +83,11 @@ export const ShowNotifications = () => {
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -1,52 +1,134 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
});
type PasswordForm = z.infer<typeof PasswordSchema>;
export const Disable2FA = () => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.auth.disable2FA.useMutation();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const handleSubmit = async (formData: PasswordForm) => {
setIsLoading(true);
try {
const result = await authClient.twoFactor.disable({
password: formData.password,
});
if (result.error) {
form.setError("password", {
message: result.error.message,
});
toast.error(result.error.message);
return;
}
toast.success("2FA disabled successfully");
utils.auth.get.invalidate();
setIsOpen(false);
} catch (error) {
form.setError("password", {
message: "Connection error. Please try again.",
});
toast.error("Connection error. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<AlertDialog>
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Disable 2FA
</Button>
<Button variant="destructive">Disable 2FA</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the 2FA
This action cannot be undone. This will permanently disable
Two-Factor Authentication for your account.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync()
.then(() => {
utils.auth.get.invalidate();
toast.success("2FA Disabled");
})
.catch(() => {
toast.error("Error disabling 2FA");
});
}}
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to disable 2FA
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset();
setIsOpen(false);
}}
>
Cancel
</Button>
<Button type="submit" variant="destructive" isLoading={isLoading}>
Disable 2FA
</Button>
</div>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);

View File

@@ -17,144 +17,315 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Fingerprint } from "lucide-react";
import { useEffect } from "react";
import { Fingerprint, QrCode } from "lucide-react";
import QRCode from "qrcode";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Enable2FASchema = z.object({
const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
});
const PinSchema = z.object({
pin: z.string().min(6, {
message: "Pin is required",
}),
});
type Enable2FA = z.infer<typeof Enable2FASchema>;
type PasswordForm = z.infer<typeof PasswordSchema>;
type PinForm = z.infer<typeof PinSchema>;
type TwoFactorEnableResponse = {
totpURI: string;
backupCodes: string[];
};
type TwoFactorSetupData = {
qrCodeUrl: string;
secret: string;
totpURI: string;
};
export const Enable2FA = () => {
const utils = api.useUtils();
const { data: session } = authClient.useSession();
const [data, setData] = useState<TwoFactorSetupData | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const { data } = api.auth.generate2FASecret.useQuery(undefined, {
refetchOnWindowFocus: false,
const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsPasswordLoading(true);
try {
const { data: enableData } = await authClient.twoFactor.enable({
password: formData.password,
});
if (!enableData) {
throw new Error("No data received from server");
}
if (enableData.backupCodes) {
setBackupCodes(enableData.backupCodes);
}
if (enableData.totpURI) {
const qrCodeUrl = await QRCode.toDataURL(enableData.totpURI);
setData({
qrCodeUrl,
secret: enableData.totpURI.split("secret=")[1]?.split("&")[0] || "",
totpURI: enableData.totpURI,
});
setStep("verify");
toast.success("Scan the QR code with your authenticator app");
} else {
throw new Error("No TOTP URI received from server");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error setting up 2FA",
);
passwordForm.setError("password", {
message: "Error verifying password",
});
} finally {
setIsPasswordLoading(false);
}
};
const handleVerifySubmit = async (formData: PinForm) => {
try {
const result = await authClient.twoFactor.verifyTotp({
code: formData.pin,
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
pinForm.setError("pin", {
message: "Invalid code. Please try again.",
});
toast.error("Invalid verification code");
return;
}
throw result.error;
}
if (!result.data) {
throw new Error("No response received from server");
}
toast.success("2FA configured successfully");
utils.auth.get.invalidate();
setIsDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
const errorMessage =
error.message === "Failed to fetch"
? "Connection error. Please check your internet connection."
: error.message;
pinForm.setError("pin", {
message: errorMessage,
});
toast.error(errorMessage);
} else {
pinForm.setError("pin", {
message: "Error verifying code",
});
toast.error("Error verifying 2FA code");
}
}
};
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const { mutateAsync, isLoading, error, isError } =
api.auth.verify2FASetup.useMutation();
const form = useForm<Enable2FA>({
const pinForm = useForm<PinForm>({
resolver: zodResolver(PinSchema),
defaultValues: {
pin: "",
},
resolver: zodResolver(Enable2FASchema),
});
useEffect(() => {
form.reset({
pin: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (!isDialogOpen) {
setStep("password");
setData(null);
setBackupCodes([]);
passwordForm.reset();
pinForm.reset();
}
}, [isDialogOpen, passwordForm, pinForm]);
const onSubmit = async (formData: Enable2FA) => {
await mutateAsync({
pin: formData.pin,
secret: data?.secret || "",
})
.then(async () => {
toast.success("2FA Verified");
utils.auth.get.invalidate();
})
.catch(() => {
toast.error("Error verifying the 2FA");
});
};
return (
<Dialog>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<Fingerprint className="size-4 text-muted-foreground" />
Enable 2FA
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen max-sm:overflow-y-auto sm:max-w-xl ">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>2FA Setup</DialogTitle>
<DialogDescription>Add a 2FA to your account</DialogDescription>
<DialogDescription>
{step === "password"
? "Enter your password to begin 2FA setup"
: "Scan the QR code and verify with your authenticator app"}
</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form-add-2FA"
onSubmit={form.handleSubmit(onSubmit)}
className="grid sm:grid-cols-2 w-full gap-4"
>
<div className="flex flex-col gap-4 justify-center items-center">
<span className="text-sm text-muted-foreground">
{data?.qrCodeUrl ? "Scan the QR code to add 2FA" : ""}
</span>
<img
src={data?.qrCodeUrl}
alt="qrCode"
className="rounded-lg w-fit"
/>
<div className="flex flex-col gap-2">
<span className="text-sm text-muted-foreground text-center">
{data?.secret ? `Secret: ${data?.secret}` : ""}
</span>
</div>
</div>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Pin</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription className="max-md:text-center">
Please enter the 6 digits code provided by your
authenticator app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-add-2FA"
type="submit"
{step === "password" ? (
<Form {...passwordForm}>
<form
id="password-form"
onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)}
className="space-y-4"
>
Submit 2FA
</Button>
</DialogFooter>
</Form>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to enable 2FA
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
isLoading={isPasswordLoading}
>
Continue
</Button>
</form>
</Form>
) : (
<Form {...pinForm}>
<form
id="pin-form"
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
className="space-y-6"
>
<div className="flex flex-col gap-6 justify-center items-center">
{data?.qrCodeUrl ? (
<>
<div className="flex flex-col items-center gap-4 p-6 border rounded-lg">
<QrCode className="size-5 text-muted-foreground" />
<span className="text-sm font-medium">
Scan this QR code with your authenticator app
</span>
<img
src={data.qrCodeUrl}
alt="2FA QR Code"
className="rounded-lg w-48 h-48"
/>
<div className="flex flex-col gap-2 text-center">
<span className="text-sm text-muted-foreground">
Can't scan the QR code?
</span>
<span className="text-xs font-mono bg-muted p-2 rounded">
{data.secret}
</span>
</div>
</div>
{backupCodes && backupCodes.length > 0 && (
<div className="w-full space-y-3 border rounded-lg p-4">
<h4 className="font-medium">Backup Codes</h4>
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code
key={index}
className="bg-muted p-2 rounded text-sm font-mono"
>
{code}
</code>
))}
</div>
<p className="text-sm text-muted-foreground">
Save these backup codes in a secure place. You can use
them to access your account if you lose access to your
authenticator device.
</p>
</div>
)}
</>
) : (
<div className="flex items-center justify-center w-full h-48 bg-muted rounded-lg">
<QrCode className="size-8 text-muted-foreground animate-pulse" />
</div>
)}
</div>
<FormField
control={pinForm.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center items-center">
<FormLabel>Verification Code</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Enter the 6-digit code from your authenticator app
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
isLoading={isPasswordLoading}
>
Enable 2FA
</Button>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);

View File

@@ -14,7 +14,7 @@ import Link from "next/link";
import { toast } from "sonner";
export const GenerateToken = () => {
const { data, refetch } = api.auth.get.useQuery();
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync: generateToken, isLoading: isLoadingToken } =
api.auth.generateToken.useMutation();
@@ -51,7 +51,7 @@ export const GenerateToken = () => {
<Label>Token</Label>
<ToggleVisibilityInput
placeholder="Token"
value={data?.token || ""}
value={data?.user?.token || ""}
disabled
/>
</div>

View File

@@ -1,4 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { authClient } from "@/lib/auth-client";
import { generateSHA256Hash } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -54,7 +56,10 @@ const randomImages = [
];
export const ProfileForm = () => {
const { data, refetch, isLoading } = api.auth.get.useQuery();
const utils = api.useUtils();
const { mutateAsync: disable2FA, isLoading: isDisabling } =
api.auth.disable2FA.useMutation();
const { data, refetch, isLoading } = api.user.get.useQuery();
const {
mutateAsync,
isLoading: isUpdating,
@@ -73,9 +78,9 @@ export const ProfileForm = () => {
const form = useForm<Profile>({
defaultValues: {
email: data?.email || "",
email: data?.user?.email || "",
password: "",
image: data?.image || "",
image: data?.user?.image || "",
currentPassword: "",
},
resolver: zodResolver(profileSchema),
@@ -84,14 +89,14 @@ export const ProfileForm = () => {
useEffect(() => {
if (data) {
form.reset({
email: data?.email || "",
email: data?.user?.email || "",
password: "",
image: data?.image || "",
image: data?.user?.image || "",
currentPassword: "",
});
if (data.email) {
generateSHA256Hash(data.email).then((hash) => {
if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => {
setGravatarHash(hash);
});
}
@@ -130,7 +135,7 @@ export const ProfileForm = () => {
{t("settings.profile.description")}
</CardDescription>
</div>
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">

View File

@@ -35,7 +35,7 @@ const profileSchema = z.object({
type Profile = z.infer<typeof profileSchema>;
export const RemoveSelfAccount = () => {
const { data } = api.auth.get.useQuery();
const { data } = api.user.get.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.auth.removeSelfAccount.useMutation();
const { t } = useTranslation("settings");

View File

@@ -7,7 +7,7 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
const { data, refetch } = api.admin.one.useQuery(undefined, {
const { data, refetch } = api.user.get.useQuery(undefined, {
enabled: !serverId,
});
@@ -20,7 +20,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
},
);
const enabled = data?.enableDockerCleanup || server?.enableDockerCleanup;
const enabled = data?.user.enableDockerCleanup || server?.enableDockerCleanup;
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();

View File

@@ -0,0 +1,636 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { zodResolver } from "@hookform/resolvers/zod";
import { Eye, EyeOff, LayoutDashboardIcon, RefreshCw } 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({
metricsConfig: z.object({
server: z.object({
refreshRate: z.number().min(2, {
message: "Server Refresh Rate is required",
}),
port: z.number().min(1, {
message: "Port is required",
}),
token: z.string(),
urlCallback: z.string(),
retentionDays: z.number().min(1, {
message: "Retention days must be at least 1",
}),
thresholds: z.object({
cpu: z.number().min(0),
memory: z.number().min(0),
}),
cronJob: z.string().min(1, {
message: "Cron Job is required",
}),
}),
containers: z.object({
refreshRate: z.number().min(2, {
message: "Container Refresh Rate is required",
}),
services: z.object({
include: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
}),
}),
}),
});
type Schema = z.infer<typeof Schema>;
export const SetupMonitoring = ({ serverId }: Props) => {
const { data, isLoading } = serverId
? api.server.one.useQuery(
{
serverId: serverId || "",
},
{
enabled: !!serverId,
},
)
: api.user.get.useQuery();
const url = useUrl();
const { data: projects } = api.project.all.useQuery();
const extractServicesFromProjects = (projects: any[] | undefined) => {
if (!projects) return [];
const allServices = projects.flatMap((project) => {
const services = extractServices(project);
return serverId
? services
.filter((service) => service.serverId === serverId)
.map((service) => service.appName)
: services.map((service) => service.appName);
});
return [...new Set(allServices)];
};
const services = extractServicesFromProjects(projects);
const form = useForm<Schema>({
resolver: zodResolver(Schema),
defaultValues: {
metricsConfig: {
server: {
refreshRate: 20,
port: 4500,
token: "",
urlCallback: `${url}/api/trpc/notification.receiveNotification`,
retentionDays: 7,
thresholds: {
cpu: 0,
memory: 0,
},
cronJob: "",
},
containers: {
refreshRate: 20,
services: {
include: [],
exclude: [],
},
},
},
},
});
useEffect(() => {
if (data) {
form.reset({
metricsConfig: {
server: {
refreshRate: data?.metricsConfig?.server?.refreshRate,
port: data?.metricsConfig?.server?.port,
token: data?.metricsConfig?.server?.token || generateToken(),
urlCallback:
data?.metricsConfig?.server?.urlCallback ||
`${url}/api/trpc/notification.receiveNotification`,
retentionDays: data?.metricsConfig?.server?.retentionDays || 5,
thresholds: {
cpu: data?.metricsConfig?.server?.thresholds?.cpu,
memory: data?.metricsConfig?.server?.thresholds?.memory,
},
cronJob: data?.metricsConfig?.server?.cronJob || "0 0 * * *",
},
containers: {
refreshRate: data?.metricsConfig?.containers?.refreshRate,
services: {
include: data?.metricsConfig?.containers?.services?.include,
exclude: data?.metricsConfig?.containers?.services?.exclude,
},
},
},
});
}
}, [data, url]);
const [search, setSearch] = useState("");
const [searchExclude, setSearchExclude] = useState("");
const [showToken, setShowToken] = useState(false);
const availableServices = services?.filter(
(service) =>
!form
.watch("metricsConfig.containers.services.include")
?.some((s) => s === service) &&
!form
.watch("metricsConfig.containers.services.exclude")
?.includes(service) &&
service.toLowerCase().includes(search.toLowerCase()),
);
const availableServicesToExclude = [
...(services?.filter(
(service) =>
!form
.watch("metricsConfig.containers.services.exclude")
?.includes(service) &&
!form
.watch("metricsConfig.containers.services.include")
?.some((s) => s === service) &&
service.toLowerCase().includes(searchExclude.toLowerCase()),
) ?? []),
...(!form.watch("metricsConfig.containers.services.exclude")?.includes("*")
? ["*"]
: []),
];
const { mutateAsync } = serverId
? api.server.setupMonitoring.useMutation()
: api.admin.setupMonitoring.useMutation();
const generateToken = () => {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
};
const onSubmit = async (values: Schema) => {
await mutateAsync({
serverId: serverId || "",
metricsConfig: values.metricsConfig,
})
.then(() => {
toast.success("Server updated successfully");
})
.catch(() => {
toast.error("Error updating the server");
});
};
return (
<>
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<LayoutDashboardIcon className="size-6 text-muted-foreground self-center" />
Monitoring
</CardTitle>
<CardDescription>
Monitor your servers and containers in realtime with notifications
when they reach their thresholds.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 py-6 border-t">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-4"
>
<AlertBlock>
Using a lower refresh rate will make your CPU and memory usage
higher, we recommend 30-60 seconds
</AlertBlock>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="metricsConfig.server.refreshRate"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Server Refresh Rate</FormLabel>
<FormControl>
<NumberInput placeholder="10" {...field} />
</FormControl>
<FormDescription>
Please set the refresh rate for the server in seconds
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.containers.refreshRate"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Container Refresh Rate</FormLabel>
<FormControl>
<NumberInput placeholder="10" {...field} />
</FormControl>
<FormDescription>
Please set the refresh rate for the containers in seconds
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.cronJob"
render={({ field }) => (
<FormItem>
<FormLabel>Cron Job</FormLabel>
<FormControl>
<Input {...field} placeholder="0 0 * * *" />
</FormControl>
<FormDescription>
Cron job for cleaning up metrics
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.retentionDays"
render={({ field }) => (
<FormItem>
<FormLabel>Server Retention Days</FormLabel>
<FormControl>
<NumberInput {...field} />
</FormControl>
<FormDescription>
Number of days to retain server metrics data
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.port"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Port</FormLabel>
<FormControl>
<NumberInput placeholder="4500" {...field} />
</FormControl>
<FormDescription>
Please set the port for the metrics server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.containers.services.include"
render={({ field }) => (
<FormItem>
<FormLabel>Include Services</FormLabel>
<FormControl>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Add Service</Button>
</PopoverTrigger>
<PopoverContent
className="w-[300px] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search service..."
value={search}
onValueChange={setSearch}
/>
{availableServices?.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No services available.
</div>
) : (
<>
<CommandEmpty>
No service found.
</CommandEmpty>
<CommandGroup>
{availableServices?.map((service) => (
<CommandItem
key={service}
value={service}
onSelect={() => {
field.onChange([
...(field.value ?? []),
service,
]);
setSearch("");
}}
>
{service}
</CommandItem>
))}
</CommandGroup>
</>
)}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-wrap gap-2">
{field.value?.map((service) => (
<Badge
key={service}
variant="secondary"
className="flex items-center gap-2"
>
{service}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0"
onClick={() => {
field.onChange(
field.value?.filter((s) => s !== service),
);
}}
>
×
</Button>
</Badge>
))}
<FormDescription>
Services to monitor.
</FormDescription>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.containers.services.exclude"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Services</FormLabel>
<FormControl>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Add Service</Button>
</PopoverTrigger>
<PopoverContent
className="w-[300px] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search service..."
value={searchExclude}
onValueChange={setSearchExclude}
/>
{availableServicesToExclude?.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No services available.
</div>
) : (
<>
<CommandEmpty>
No service found.
</CommandEmpty>
<CommandGroup>
{availableServicesToExclude.map(
(service) => (
<CommandItem
key={service}
value={service}
onSelect={() => {
field.onChange([
...(field.value ?? []),
service,
]);
setSearchExclude("");
}}
>
{service}
</CommandItem>
),
)}
</CommandGroup>
</>
)}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-wrap gap-2">
{field.value?.map((service, index) => (
<Badge
key={service}
variant="secondary"
className="flex items-center gap-2"
>
{service}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0"
onClick={() => {
field.onChange(
field.value?.filter((_, i) => i !== index),
);
}}
>
×
</Button>
</Badge>
))}
<FormDescription>
Services to exclude from monitoring
</FormDescription>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.thresholds.cpu"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Threshold (%)</FormLabel>
<FormControl>
<NumberInput {...field} />
</FormControl>
<FormDescription>
Alert when CPU usage exceeds this percentage
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.thresholds.memory"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Threshold (%)</FormLabel>
<FormControl>
<NumberInput {...field} />
</FormControl>
<FormDescription>
Alert when memory usage exceeds this percentage
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.token"
render={({ field }) => (
<FormItem>
<FormLabel>Metrics Token</FormLabel>
<FormControl>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showToken ? "text" : "password"}
placeholder="Enter your metrics token"
{...field}
/>
<Button
type="button"
variant="secondary"
size="icon"
className="absolute right-0 top-1/2 -translate-y-1/2"
onClick={() => setShowToken(!showToken)}
title={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const newToken = generateToken();
form.setValue(
"metricsConfig.server.token",
newToken,
);
toast.success("Token generated successfully");
}}
title="Generate new token"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</FormControl>
<FormDescription>
Token for authenticating metrics requests
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metricsConfig.server.urlCallback"
render={({ field }) => (
<FormItem>
<FormLabel>Metrics Callback URL</FormLabel>
<FormControl>
<Input
placeholder="https://your-callback-url.com"
{...field}
/>
</FormControl>
<FormDescription>
URL where metrics will be sent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={form.formState.isSubmitting}>
Save changes
</Button>
</div>
</form>
</Form>
</CardContent>
</>
);
};

View File

@@ -19,6 +19,7 @@ import {
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
@@ -30,6 +31,7 @@ import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { EditScript } from "./edit-script";
import { GPUSupport } from "./gpu-support";
import { SecurityAudit } from "./security-audit";
import { SetupMonitoring } from "./setup-monitoring";
import { ValidateServer } from "./validate-server";
interface Props {
@@ -48,7 +50,7 @@ export const SetupServer = ({ serverId }: Props) => {
);
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
@@ -112,11 +114,19 @@ export const SetupServer = ({ serverId }: Props) => {
</AlertBlock>
<Tabs defaultValue="ssh-keys">
<TabsList className="grid grid-cols-5 w-[700px]">
<TabsList
className={cn(
"grid w-[700px]",
isCloud ? "grid-cols-6" : "grid-cols-5",
)}
>
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
@@ -309,6 +319,16 @@ export const SetupServer = ({ serverId }: Props) => {
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"

View File

@@ -0,0 +1,31 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring";
interface Props {
url: string;
token: string;
}
export const ShowMonitoringModal = ({ url, token }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Monitoring
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<div className="flex gap-4 py-4 w-full">
<ShowPaidMonitoring BASE_URL={url} token={token} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -38,6 +38,7 @@ import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
@@ -314,6 +315,16 @@ export const ShowServers = () => {
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>

View File

@@ -35,6 +35,7 @@ export const CreateSSHKey = () => {
description: "Used on Dokploy Cloud",
privateKey: keys.privateKey,
publicKey: keys.publicKey,
organizationId: "",
});
await refetch();
} catch (error) {

View File

@@ -22,7 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { sshKeyCreate, type sshKeyType } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { DownloadIcon, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -78,6 +78,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
const onSubmit = async (data: SSHKey) => {
await mutateAsync({
...data,
organizationId: "",
sshKeyId: sshKeyId || "",
})
.then(async () => {
@@ -111,6 +112,26 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
toast.error("Error generating the SSH Key");
});
const downloadKey = (
content: string,
defaultFilename: string,
keyType: "private" | "public",
) => {
const keyName = form.watch("name");
const filename = keyName
? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultFilename}`
: `${keyType}_${defaultFilename}`;
const blob = new Blob([content], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
@@ -245,7 +266,41 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
</FormItem>
)}
/>
<DialogFooter>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-4">
{form.watch("privateKey") && (
<Button
type="button"
variant="outline"
size="default"
onClick={() =>
downloadKey(form.watch("privateKey"), "id_rsa", "private")
}
className="flex items-center gap-2"
>
<DownloadIcon className="h-4 w-4" />
Private Key
</Button>
)}
{form.watch("publicKey") && (
<Button
type="button"
variant="outline"
size="default"
onClick={() =>
downloadKey(
form.watch("publicKey"),
"id_rsa.pub",
"public",
)
}
className="flex items-center gap-2"
>
<DownloadIcon className="h-4 w-4" />
Public Key
</Button>
)}
</div>
<Button isLoading={isLoading} type="submit">
{sshKeyId ? "Update" : "Create"}
</Button>

View File

@@ -19,6 +19,14 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
@@ -27,62 +35,70 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addUser = z.object({
const addInvitation = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]),
});
type AddUser = z.infer<typeof addUser>;
type AddInvitation = z.infer<typeof addInvitation>;
export const AddUser = () => {
export const AddInvitation = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization();
const { mutateAsync, isError, error, isLoading } =
api.admin.createUserInvitation.useMutation();
const form = useForm<AddUser>({
const form = useForm<AddInvitation>({
defaultValues: {
email: "",
role: "member",
},
resolver: zodResolver(addUser),
resolver: zodResolver(addInvitation),
});
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddUser) => {
await mutateAsync({
const onSubmit = async (data: AddInvitation) => {
setIsLoading(true);
const result = await authClient.organization.inviteMember({
email: data.email.toLowerCase(),
})
.then(async () => {
toast.success("Invitation created");
await utils.user.all.invalidate();
setOpen(false);
})
.catch(() => {
toast.error("Error creating the invitation");
});
role: data.role,
organizationId: activeOrganization?.id,
});
if (result.error) {
setError(result.error.message || "");
} else {
toast.success("Invitation created");
setError(null);
setOpen(false);
}
utils.organization.allInvitations.invalidate();
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
<PlusIcon className="h-4 w-4" /> Add User
<PlusIcon className="h-4 w-4" /> Add Invitation
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{error && <AlertBlock type="error">{error}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-user"
id="hook-form-add-invitation"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
@@ -104,10 +120,39 @@ export const AddUser = () => {
);
}}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the role for the new user
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isLoading}
form="hook-form-add-user"
form="hook-form-add-invitation"
type="submit"
>
Create

View File

@@ -30,8 +30,8 @@ import { toast } from "sonner";
import { z } from "zod";
const addPermissions = z.object({
accesedProjects: z.array(z.string()).optional(),
accesedServices: z.array(z.string()).optional(),
accessedProjects: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
@@ -52,7 +52,7 @@ interface Props {
export const AddUserPermissions = ({ userId }: Props) => {
const { data: projects } = api.project.all.useQuery();
const { data, refetch } = api.user.byUserId.useQuery(
const { data, refetch } = api.auth.one.useQuery(
{
userId,
},
@@ -62,12 +62,12 @@ export const AddUserPermissions = ({ userId }: Props) => {
);
const { mutateAsync, isError, error, isLoading } =
api.admin.assignPermissions.useMutation();
api.user.assignPermissions.useMutation();
const form = useForm<AddPermissions>({
defaultValues: {
accesedProjects: [],
accesedServices: [],
accessedProjects: [],
accessedServices: [],
},
resolver: zodResolver(addPermissions),
});
@@ -75,8 +75,8 @@ export const AddUserPermissions = ({ userId }: Props) => {
useEffect(() => {
if (data) {
form.reset({
accesedProjects: data.accesedProjects || [],
accesedServices: data.accesedServices || [],
accessedProjects: data.accessedProjects || [],
accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
@@ -92,14 +92,14 @@ export const AddUserPermissions = ({ userId }: Props) => {
const onSubmit = async (data: AddPermissions) => {
await mutateAsync({
userId,
id: userId,
canCreateServices: data.canCreateServices,
canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accesedProjects: data.accesedProjects || [],
accesedServices: data.accesedServices || [],
accessedProjects: data.accessedProjects || [],
accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
@@ -318,7 +318,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
/>
<FormField
control={form.control}
name="accesedProjects"
name="accessedProjects"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
@@ -339,7 +339,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
<FormField
key={`project-${index}`}
control={form.control}
name="accesedProjects"
name="accessedProjects"
render={({ field }) => {
return (
<FormItem
@@ -380,7 +380,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
<FormField
key={`project-${index}`}
control={form.control}
name="accesedServices"
name="accessedServices"
render={({ field }) => {
return (
<FormItem

View File

@@ -0,0 +1,208 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { format, isPast } from "date-fns";
import { Mail, MoreHorizontal, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { AddInvitation } from "./add-invitation";
export const ShowInvitations = () => {
const { data, isLoading, refetch } =
api.organization.allInvitations.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Mail className="size-6 text-muted-foreground self-center" />
Invitations
</CardTitle>
<CardDescription>
Create invitations to your organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Users className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
Invite users to your organization
</span>
<AddInvitation />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<Table>
<TableCaption>See all invitations</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">
Expires At
</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((invitation) => {
const isExpired = isPast(
new Date(invitation.expiresAt),
);
return (
<TableRow key={invitation.id}>
<TableCell className="w-[100px]">
{invitation.email}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
invitation.role === "owner"
? "default"
: "secondary"
}
>
{invitation.role}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
variant={
invitation.status === "pending"
? "secondary"
: invitation.status === "canceled"
? "destructive"
: "default"
}
>
{invitation.status}
</Badge>
</TableCell>
<TableCell className="text-center">
{format(new Date(invitation.expiresAt), "PPpp")}{" "}
{isExpired ? (
<span className="text-muted-foreground">
(Expired)
</span>
) : null}
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{!isExpired && (
<>
{invitation.status === "pending" && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
copy(
`${origin}/invitation?token=${invitation.id}`,
);
toast.success(
"Invitation Copied to clipboard",
);
}}
>
Copy Invitation
</DropdownMenuItem>
)}
{invitation.status === "pending" && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={async (e) => {
const result =
await authClient.organization.cancelInvitation(
{
invitationId: invitation.id,
},
);
if (result.error) {
toast.error(
result.error.message,
);
} else {
toast.success(
"Invitation deleted",
);
refetch();
}
}}
>
Cancel Invitation
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddInvitation />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -23,22 +24,19 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { AddUserPermissions } from "./add-permissions";
import { AddUser } from "./add-user";
import { DialogAction } from "@/components/shared/dialog-action";
import { Loader2 } from "lucide-react";
export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.admin.removeUser.useMutation();
const { mutateAsync, isLoading: isRemoving } = api.user.remove.useMutation();
return (
<div className="w-full">
@@ -67,7 +65,6 @@ export const ShowUsers = () => {
<span className="text-base text-muted-foreground">
Invite users to your Dokploy account
</span>
<AddUser />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -76,43 +73,41 @@ export const ShowUsers = () => {
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center">
Expiration
Created At
</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((user) => {
{data?.map((member) => {
return (
<TableRow key={user.userId}>
<TableRow key={member.id}>
<TableCell className="w-[100px]">
{user.auth.email}
{member.user.email}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
user.isRegistered ? "default" : "secondary"
member.role === "owner"
? "default"
: "secondary"
}
>
{user.isRegistered
? "Registered"
: "Not Registered"}
{member.role}
</Badge>
</TableCell>
<TableCell className="text-center">
{user.auth.is2FAEnabled
? "2FA Enabled"
: "2FA Not Enabled"}
{member.user.twoFactorEnabled
? "Enabled"
: "Disabled"}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-center">
<span className="text-sm text-muted-foreground">
{format(
new Date(user.expirationDate),
"PPpp",
)}
{format(new Date(member.createdAt), "PPpp")}
</span>
</TableCell>
@@ -131,56 +126,63 @@ export const ShowUsers = () => {
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{!user.isRegistered && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
copy(
`${origin}/invitation?token=${user.token}`,
);
toast.success(
"Invitation Copied to clipboard",
);
}}
>
Copy Invitation
</DropdownMenuItem>
)}
{user.isRegistered && (
{member.role !== "owner" && (
<AddUserPermissions
userId={user.userId}
userId={member.user.id}
/>
)}
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
authId: user.authId,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
{member.role !== "owner" && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
if (isCloud) {
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
if (!error) {
toast.success(
"User deleted successfully",
);
refetch();
} else {
toast.error(
"Error deleting user",
);
}
} else {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}
}}
>
Delete User
</DropdownMenuItem>
</DialogAction>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete User
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -189,10 +191,6 @@ export const ShowUsers = () => {
})}
</TableBody>
</Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddUser />
</div>
</div>
)}
</>

View File

@@ -52,7 +52,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data: user, refetch } = api.admin.one.useQuery();
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -65,14 +65,14 @@ export const WebDomain = () => {
resolver: zodResolver(addServerDomain),
});
useEffect(() => {
if (user) {
if (data) {
form.reset({
domain: user?.host || "",
certificateType: user?.certificateType,
letsEncryptEmail: user?.letsEncryptEmail || "",
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
});
}
}, [form, form.reset, user]);
}, [form, form.reset, data]);
const onSubmit = async (data: AddServerDomain) => {
await mutateAsync({

View File

@@ -21,7 +21,7 @@ interface Props {
}
export const WebServer = ({ className }: Props) => {
const { t } = useTranslation("settings");
const { data } = api.admin.one.useQuery();
const { data } = api.user.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
@@ -58,7 +58,7 @@ export const WebServer = ({ className }: Props) => {
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.serverIp}
Server IP: {data?.user.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}

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 { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { getLocalServerData } from "./local-server-config";
@@ -37,6 +38,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
foreground: "currentColor",
},
});
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -54,6 +56,8 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
// @ts-ignore
term.open(termRef.current);
@@ -68,7 +72,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
return (
<div className="flex flex-col gap-4">
<div className="w-full h-full bg-transparent border 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>

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