Compare commits
577 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9522b8aa8d | |||
| fd881b4c61 | |||
| 424ca715c9 | |||
| fe3c5f7a1b | |||
| e601fc93d6 | |||
| 8f7076e4ef | |||
| bcc2c38ab7 | |||
| 529d227e74 | |||
| 6b56efcf14 | |||
| f23c8a9243 | |||
| 98660567e5 | |||
| 5bf2c27030 | |||
| 2f4b300274 | |||
| da5852d330 | |||
| 81fa8544dd | |||
| e407286c39 | |||
| 908f7b8985 | |||
| 98edbcaeb2 | |||
| 482b7e8017 | |||
| acf295a259 | |||
| a0667da4de | |||
| f95ad86d5b | |||
| 72f03c75c8 | |||
| 14cb8f0014 | |||
| 0d57870311 | |||
| fb6fca152f | |||
| 11a33455ce | |||
| 124076ed72 | |||
| 294f591152 | |||
| f9414dc815 | |||
| 99c1e0e262 | |||
| f6c344873d | |||
| f8f768337e | |||
| c64694e40f | |||
| 116791f29f | |||
| 69fd7e0b7d | |||
| ac539d1f90 | |||
| 9774a17f7e | |||
| 1f7b0c076c | |||
| 51c6c37ea6 | |||
| 790de8cfa6 | |||
| f49f2ecb6c | |||
| 9647fb358b | |||
| e9e28ae26a | |||
| 60032c186d | |||
| e7011ca0a5 | |||
| 0382113567 | |||
| 18fe633979 | |||
| 2d8b4d9c2a | |||
| d4d6050862 | |||
| 6bed5265e2 | |||
| a1b4fdf624 | |||
| b9ea1573ea | |||
| 7a56545e9e | |||
| 2bf9b66af7 | |||
| 215a6faae9 | |||
| 61f37e0260 | |||
| b2c434a1fd | |||
| 0d2bcbf25b | |||
| a3d1838a8c | |||
| 692fb1a68c | |||
| c71d915a4b | |||
| a0b5dec8b9 | |||
| e2f71b10ec | |||
| 743e4fce0b | |||
| d97c608323 | |||
| 89baa3cabf | |||
| d83712b093 | |||
| 806309fc33 | |||
| 70f6343a2c | |||
| 03dca869c8 | |||
| 84a10d4eb1 | |||
| 554a77fbca | |||
| e12f5e41ff | |||
| 79ad003bc6 | |||
| fc417022c9 | |||
| f427d9f1c4 | |||
| 409f185f7e | |||
| 6b080455ff | |||
| da726ecd15 | |||
| a8f61878ca | |||
| 73e929f0cf | |||
| 60420c3e32 | |||
| a02e933375 | |||
| 73df6519f0 | |||
| ac3a34ff58 | |||
| 8d85b521c8 | |||
| 6d89010a1f | |||
| 8c85fdd7b5 | |||
| cc535b0d0a | |||
| d275b56dc1 | |||
| ad1fc9b9c7 | |||
| 1ea6fb9300 | |||
| 9d96ab8f6a | |||
| 4f518d2315 | |||
| 7377476f97 | |||
| a55bd4458c | |||
| 22cb7f7d8f | |||
| 7b46595503 | |||
| aa30f6ef98 | |||
| 5107cd28c4 | |||
| b537d73a55 | |||
| 9a5c49bd08 | |||
| 19cf204dc4 | |||
| a75baba1f6 | |||
| a2dd45fd69 | |||
| b90cdb8686 | |||
| 16e79c6546 | |||
| f3fbff291f | |||
| f994088d38 | |||
| 091a49ff78 | |||
| 357313b555 | |||
| 3b64d8b0a5 | |||
| 6fa95d9f4f | |||
| 15ff5ede7e | |||
| d89c826e18 | |||
| 5e485fb87e | |||
| 6b7e8bef1d | |||
| 5cb2312806 | |||
| aa7543ad0c | |||
| b6df80dcef | |||
| c0ad75cc4d | |||
| 612002ec33 | |||
| bb96b96e24 | |||
| 49fc63d422 | |||
| 350315fa56 | |||
| fa859a3b5d | |||
| b2f5110871 | |||
| 18d0cae6b0 | |||
| a6f380444a | |||
| 631333f48e | |||
| f279317105 | |||
| f09b03338e | |||
| 6e011ae70e | |||
| 854fbe53be | |||
| 1ef252fbc2 | |||
| aaebe01892 | |||
| 83efffb7f9 | |||
| b89aa4488c | |||
| 2029148e7c | |||
| 8b33414c55 | |||
| 0e177a7a4c | |||
| 11fc6a61d5 | |||
| ca5ab6edf5 | |||
| bbefca71e5 | |||
| 001adcee62 | |||
| 4870cdd76f | |||
| 3dc8e87a27 | |||
| 1cd069df5e | |||
| 4dd1a960c1 | |||
| 2c8dc3e6a7 | |||
| 49f8b3b7f6 | |||
| dd9dc34308 | |||
| a8b41945d0 | |||
| fa776c34de | |||
| a3a4bbbb83 | |||
| 52e1276c8d | |||
| 241be5eaee | |||
| a32903218e | |||
| 6620fc8570 | |||
| 388a4d93e4 | |||
| 85898d3531 | |||
| 1f2e1691f9 | |||
| 2693f5f496 | |||
| 854f7d7f2e | |||
| 1cac67d4c5 | |||
| 72970720d2 | |||
| b5c75caea0 | |||
| f421fd771f | |||
| 748f3a3a4f | |||
| 59ccf6181e | |||
| c7f5e6b5b0 | |||
| 10f99673c5 | |||
| aff5e8f44d | |||
| 7db5a48e35 | |||
| fe73e76fe9 | |||
| faa22feebf | |||
| 9773c02e7d | |||
| 628902bb70 | |||
| c2e981b35a | |||
| 2f40eeb49f | |||
| cfb2501576 | |||
| 4057906b2c | |||
| 93fe97b94d | |||
| aa2df465a0 | |||
| 350438b2c4 | |||
| 075499b695 | |||
| b361adbe30 | |||
| c448322367 | |||
| b6d4b58f86 | |||
| bbb00ff36f | |||
| 07dc823528 | |||
| b9ae97e5ec | |||
| dfafbdd882 | |||
| 35d0227862 | |||
| c8842cc71f | |||
| 620974217a | |||
| 392d47852d | |||
| f714cd66f7 | |||
| 425e196dfc | |||
| 1ffe617287 | |||
| ea93d197ab | |||
| 37c569a976 | |||
| 7a189bd5e5 | |||
| d3876eb7b0 | |||
| 64cb848a37 | |||
| 162e51a0af | |||
| 59b9991a2c | |||
| 97128673ff | |||
| fdac444aed | |||
| c656903772 | |||
| 61b5ab8a49 | |||
| 550df1be89 | |||
| 99c14533a5 | |||
| b759fdb6e3 | |||
| 374e1f65c6 | |||
| 3d6526de3e | |||
| 8f43c7d3d8 | |||
| e5b7ad5be2 | |||
| 8227ce1158 | |||
| 35b80178ed | |||
| 80b0dba9fe | |||
| a5497dc215 | |||
| 964fb5d251 | |||
| e24ee05337 | |||
| c6858d505f | |||
| 0ea1e47176 | |||
| 5355b91f37 | |||
| 86e7eb1087 | |||
| 043d89c03b | |||
| 1cbad1057d | |||
| d906771b18 | |||
| 76ef9c0388 | |||
| 262d96f8d7 | |||
| 41b7466325 | |||
| 76f2c5f9fc | |||
| e5a1fc9e2d | |||
| 11f9e260ed | |||
| e209bdec65 | |||
| 6432851a78 | |||
| 31fb22a7c3 | |||
| bc47e30ad3 | |||
| 58cf7c720f | |||
| 48bf73de80 | |||
| 76a3f4e86c | |||
| 3a760282f1 | |||
| 71affc0239 | |||
| 3b95d23d23 | |||
| 8cd5345f8c | |||
| fda393b5e1 | |||
| 264f9f84ed | |||
| 1d73760901 | |||
| 03a13df47b | |||
| 5160f22d91 | |||
| 3bbc2bf986 | |||
| 90f68da42f | |||
| f37438b7a7 | |||
| 826d124a5f | |||
| c162fd178b | |||
| 9b92e48a6e | |||
| 5b5c15b7f3 | |||
| 6e9cd4c11b | |||
| 8c03c73b28 | |||
| 2c10ceba5b | |||
| 2a3110cd3d | |||
| 924ea435b1 | |||
| 0e4a389910 | |||
| 720dc14ecf | |||
| 51f5f0b82d | |||
| f380a6f8cf | |||
| 437a033739 | |||
| 2b77e4d292 | |||
| 0e104ee936 | |||
| a820bf7bd0 | |||
| 09fdec8fbd | |||
| 80f6d733b9 | |||
| 838345ba46 | |||
| c2378d33b4 | |||
| 95575bc040 | |||
| 2926871eab | |||
| 5b05ea285c | |||
| 48a2e6881f | |||
| edbeaa2f77 | |||
| 48a85a620d | |||
| cc8db71ecf | |||
| e4573f74a4 | |||
| 8cff72cf59 | |||
| 73a9de7708 | |||
| 104318ab8c | |||
| 8ec4659949 | |||
| ffa8ff8427 | |||
| 4ef1339ba2 | |||
| 3702efdcb3 | |||
| bbdfbe1ab7 | |||
| cc1fc5c269 | |||
| bc32fa64bf | |||
| cfc7de9c77 | |||
| 945ab30373 | |||
| 494125227f | |||
| a4919b06f9 | |||
| 790ba406bf | |||
| e0367056bd | |||
| 4bf0dc192c | |||
| 4575a0ddce | |||
| 837cbff092 | |||
| 4108047644 | |||
| 347cf4f67d | |||
| 7f9344a556 | |||
| 8907b692c1 | |||
| 6c0d5cb601 | |||
| 5c69a146f6 | |||
| de75ae5b9e | |||
| 9c9e2c6a62 | |||
| 917c18a423 | |||
| aac81c2fba | |||
| 9e82839fb7 | |||
| ae2f74777b | |||
| 4c5d67606f | |||
| 0d2a0f91c7 | |||
| b65fa3e2c7 | |||
| e87d2e1218 | |||
| 00ae320b51 | |||
| 3d46d24038 | |||
| 8b04484ff7 | |||
| 7f9f3f683b | |||
| fb2ce06621 | |||
| 89f5e87601 | |||
| e124755363 | |||
| d0ccbe2786 | |||
| 25dec602b8 | |||
| bbf7007250 | |||
| 2b4f8ff00d | |||
| b467b58ee7 | |||
| facefeddae | |||
| 141bdb1307 | |||
| b53da61e7c | |||
| ede93323af | |||
| 8ccf79175a | |||
| 9fa330a0a0 | |||
| 3693857960 | |||
| c5f97e8bb0 | |||
| 2cb7b4d1ea | |||
| 6247cece94 | |||
| 417f5c3610 | |||
| 3e6f3bd807 | |||
| 6346c7fe9b | |||
| 11c5a3f050 | |||
| 10645b1b94 | |||
| e106dcd76a | |||
| cb30a57a59 | |||
| 98da4c0011 | |||
| fc0c316ef2 | |||
| eaf363635e | |||
| b91aa0668f | |||
| 53c2f5885a | |||
| 5717f77e00 | |||
| 3f8dfdd938 | |||
| 9e1fbedc4d | |||
| f9eb588d4c | |||
| 181ee43107 | |||
| cc30bc1897 | |||
| 1232b30e29 | |||
| 03aae46880 | |||
| 25ce947df5 | |||
| b8f486d8e4 | |||
| 6305ff7410 | |||
| b2941894cd | |||
| 83056519ec | |||
| 3cdfbbac56 | |||
| f61e85c2d6 | |||
| 217ebf8c33 | |||
| b32114f2f2 | |||
| 6209cdbe0e | |||
| afde81ef3e | |||
| fbbd71e7f2 | |||
| 54cf168b4d | |||
| c25b14976c | |||
| 39c68075fb | |||
| ce15958a9a | |||
| 8d06defbcb | |||
| 0d807a37d6 | |||
| 9a0a2d84da | |||
| 29e2be47d0 | |||
| b2e1f66dbb | |||
| bfe9ee457d | |||
| a034b70449 | |||
| 4226654772 | |||
| 4ea8ab08a3 | |||
| 702fc120af | |||
| 9453084481 | |||
| c6dbbc4135 | |||
| ddc53bcb6f | |||
| e50509ac45 | |||
| 2ddba469b2 | |||
| 4e1b2ccbaa | |||
| e0b8a2400a | |||
| 151ba569a7 | |||
| 2cb755fe44 | |||
| eeef49fd19 | |||
| 6b2626120c | |||
| e77ab26516 | |||
| dbaf6c6ce2 | |||
| 5e295f9f1e | |||
| 8d3b655517 | |||
| 64cefd52c8 | |||
| edb92ed0a5 | |||
| a8513cc0fa | |||
| 20d4ce6632 | |||
| d8c3ce30ca | |||
| d894de0784 | |||
| 572bd19df6 | |||
| 4fd399eae9 | |||
| f7f55710d1 | |||
| 18815b97ce | |||
| c4fce32a6a | |||
| 9ed5f43ea1 | |||
| 232bce0a2d | |||
| 27f975f3c5 | |||
| 5b834b4396 | |||
| 52b46e2b3e | |||
| 044fb72da9 | |||
| 0cf911bcdd | |||
| 829512dd13 | |||
| fa886c71b8 | |||
| 21191bdc50 | |||
| 1bf2fe16a2 | |||
| c35543af92 | |||
| 9bb71bd066 | |||
| f24e4f291d | |||
| 32ab9a9d32 | |||
| 8b520dec48 | |||
| 70c539ac4d | |||
| 610651066a | |||
| aaa750dbbc | |||
| a518ee83cc | |||
| de84b5113c | |||
| 2ea7847d4f | |||
| 0650fca1cf | |||
| 1b5bd0d379 | |||
| 5b6f796606 | |||
| 9d6a755486 | |||
| 9470654394 | |||
| 28feadd6c5 | |||
| af3ed04b7f | |||
| 2da99673cd | |||
| 476adcb029 | |||
| b2c8f87276 | |||
| bd4e132709 | |||
| fa8fcf8761 | |||
| 8e92b53d9f | |||
| 6f90bd3db0 | |||
| a261d8b754 | |||
| 9643b7ed1b | |||
| ec191d51bc | |||
| a5452e4b15 | |||
| 8522802f85 | |||
| 6f2e3afe07 | |||
| 70dfb41d95 | |||
| 34f04828c5 | |||
| a78799973d | |||
| 1797148951 | |||
| 67caa89591 | |||
| e3a88e9f5b | |||
| e9910c9b95 | |||
| 45e058bdc1 | |||
| 9af5404921 | |||
| 5c4ca1b699 | |||
| b6827736db | |||
| aada3f3979 | |||
| dc07078fd4 | |||
| ae8278bdb3 | |||
| 286de8cdcb | |||
| ca11d5af94 | |||
| fb04f78112 | |||
| 75fa2dfd67 | |||
| 137267e604 | |||
| 642487f4c5 | |||
| 783ad9ecda | |||
| 0213a368b9 | |||
| f1e7594b79 | |||
| 02fd52e366 | |||
| 2d5e0a51bd | |||
| 1cd82dcd4c | |||
| 5ba30d0236 | |||
| c0ea5c31eb | |||
| adee5fa25f | |||
| f9af84fd85 | |||
| 41cb381a2e | |||
| 50ca07bfb8 | |||
| 07732310c1 | |||
| 854661e2d4 | |||
| 8cac83ed98 | |||
| 5ee8e9da80 | |||
| f5c81f5882 | |||
| a415b70adf | |||
| 800a7e26e9 | |||
| 1bc9dc30f6 | |||
| 7d538ee1b8 | |||
| ac5f4cca19 | |||
| 54a5d5b9aa | |||
| 5c4ec5afc0 | |||
| 5bd6001f95 | |||
| 0fb8914b67 | |||
| 1f6ac49686 | |||
| 42887fb1d9 | |||
| f14a7808cb | |||
| a781a46f13 | |||
| 6941a12314 | |||
| f0e70a97bc | |||
| c59e3ef4ae | |||
| 2bfdc7c1ac | |||
| d831e7d765 | |||
| fe8ef5b922 | |||
| 2c150eee33 | |||
| a4d6bafe1a | |||
| 78017b8adb | |||
| ea822f66ca | |||
| a55adf12db | |||
| 84c016490c | |||
| bb7056d614 | |||
| 462b490d05 | |||
| 084050bb2f | |||
| 8d2ea7e736 | |||
| fe8d5b0d3e | |||
| de724319aa | |||
| ac91b417c3 | |||
| 229863d7ff | |||
| 8dcb3f2f85 | |||
| 15c8f84960 | |||
| f37dd03e4b | |||
| 82c97f7e1c | |||
| 91078f7a7e | |||
| d2775956e0 | |||
| 00b52fa3af | |||
| 1ac0ed3c18 | |||
| 6ec8246b46 | |||
| f5978a524d | |||
| 72030ee8fc | |||
| d6a4dd6965 | |||
| 8aa5dc85af | |||
| 5c7f99c0ee | |||
| 847cb91759 | |||
| 9e92d08261 | |||
| bf8e03aa0c | |||
| fcd05f3bb4 | |||
| a14dfc171d | |||
| b8b445eb24 | |||
| fbf4a53a1b | |||
| 0c7e810bd3 | |||
| 0502779a29 | |||
| 576d9ca894 | |||
| d8771509cd | |||
| b139749198 | |||
| bdcb5c502c | |||
| dc72df1dbd | |||
| 8be834d0c8 | |||
| c995454f69 | |||
| 854e0ebe3f | |||
| f01d2631dd | |||
| 60f8cdf3b4 | |||
| 8e5bf14623 | |||
| b063ebd6d7 | |||
| eb7d7a2d1b | |||
| f9ee088592 | |||
| 1f32d4b4dd | |||
| d3b4c2f394 | |||
| 41c00eda74 | |||
| 155af33b0c | |||
| b289146aeb | |||
| d2e32a4fd0 | |||
| 6631c95166 | |||
| 7adabcc203 | |||
| de35a935a6 | |||
| d3d668d930 | |||
| 1f60c6dd21 | |||
| 1431700642 | |||
| 12a1de56fd |
@@ -2980,3 +2980,145 @@
|
||||
* add ephemeral port warning
|
||||
* rsync: fix integrity computation
|
||||
|
||||
[9.0.2]
|
||||
* backupsite: only owner can add a site
|
||||
* remove max-height from the users view and groups view tables
|
||||
* backups: fix listing when stats is null
|
||||
* graphs: fix detection of rootfs block device
|
||||
* sidebar: ldap/openid/directory should not be visible to non-admins
|
||||
* sidebar: email domains, eventlog, settings is only for admins
|
||||
* reload dashboard on Cloudron version change
|
||||
* Always start with a fresh domains list for the apps filter
|
||||
* sysinfo: fallback to product family if product vendor is empty
|
||||
* archive: display the site name of latest backup
|
||||
* graphs: fix flickering of disk graph item
|
||||
* graphs: fix issue with live graph time calculation
|
||||
|
||||
[9.0.3]
|
||||
* Fix submit state for login form
|
||||
* Avoid flickering of SystemUpdate view when update is busy
|
||||
* backuptask: fix crash when accessing stats of old backups
|
||||
* backup sites: fix listing when status call errors
|
||||
* backups: display mail backup stats
|
||||
* Add missing autocomplete attributes on forms
|
||||
* Refresh backup site status and task in the background
|
||||
* Hide non-owner actions for backup sites
|
||||
* Move app start/stop back to the main toolbar
|
||||
* Fix styling in public page
|
||||
* network: fix ip caching bug
|
||||
* Change default footer to not have the forum link
|
||||
* Fix troubleshooting tool
|
||||
* Give domains list a larger max-height
|
||||
* Make app error compatible with previous releases
|
||||
|
||||
[9.0.4]
|
||||
* filemanager: fix missing translations
|
||||
* display backup duration
|
||||
* add hetznercloud DNS provider
|
||||
|
||||
[9.0.5]
|
||||
* access control/operators: remove deleted users and groups
|
||||
* backupcleaner: fix scoping of cleanup by site id
|
||||
* Use normal buttons for app start/stop
|
||||
* site schedule: Fix hourly display
|
||||
|
||||
[9.0.6]
|
||||
* Autofocus search in appstore view
|
||||
* All settings in sidebar should be same icon
|
||||
* Make backup content list a TableView so we can sort it by size and fileCount
|
||||
* Fix filemanager for custom apps
|
||||
* Sort apps in the grid by label
|
||||
* Filter dropdowns are searchable with more than 10 entries
|
||||
* Show app icons in the grid in grayscale if app is stopped
|
||||
* Support wildcard domain aliases in app location
|
||||
|
||||
[9.0.7]
|
||||
* externalldap: only set group members if they changed
|
||||
* Fix issue where backups remote paths were incorrectly migrated
|
||||
|
||||
[9.0.8]
|
||||
* Add explicit option to disable automatic backups
|
||||
* backups: show same filesystem warning
|
||||
* Fix tgz app backup download
|
||||
* Fix mailbox usage and quota sorting
|
||||
* Give sshfs identity files unique filenames across mounts
|
||||
* Do not share relay provider setting with view and form
|
||||
* cloudflare: ensure defaultProxyStatus in older configs
|
||||
* filter: fix domain search to include redirect/alias/secondary domains
|
||||
* Use full URLs for page preview icons and favicon
|
||||
* email: fix masquerade toggle
|
||||
|
||||
[9.0.9]
|
||||
* minio: fix issue with accepting selfsigned certs
|
||||
* applink: fix button text in edit mode
|
||||
* password reset: show error message if any
|
||||
* sshfs: use a temporary identity file for remote ssh copy
|
||||
* access control: always show the user management section
|
||||
* update: show the last update error, if any
|
||||
|
||||
[9.0.10]
|
||||
* Only enable LdapServer input fields if feature is enabled
|
||||
* Require display name to not be empty when changed from the profile view
|
||||
* access control: fix spacing
|
||||
* storage: pass limits object to backend
|
||||
|
||||
[9.0.11]
|
||||
* mail: fix count indicator when loading
|
||||
* mailinglist: fix search on name
|
||||
* backup site: fix migration with mixed formats
|
||||
|
||||
[9.0.12]
|
||||
* eventlog: always fetch enough event logs to fill the screen
|
||||
* mail: check for outbound ipv6 connectivity
|
||||
* store actual appId not oidc clientId for log in events
|
||||
* Add english labels for eventlog filtering
|
||||
* mail: when deferred, show reason
|
||||
* mail: prefer ipv4 for outbound mail
|
||||
|
||||
[9.0.13]
|
||||
* Fix issue where footer/name can break templates
|
||||
* rsync: bump empty dir limit to 80k
|
||||
* nginx: do not log query params
|
||||
* Fetch mailbox usage in the background to not delay mailbox listing
|
||||
* cloudron-support: add --check-services and add it to troubleshoot
|
||||
* Do not poll services if they are in recoveryMode
|
||||
* restore/import: fix issue where prefix was empty
|
||||
|
||||
[9.0.14]
|
||||
* Also use a temporary SSH identity file for optimized ssh remote rm -rf
|
||||
* app search: title is optional manifest
|
||||
* network: detect default ipv6 interface when no ipv4 interface
|
||||
* mail status: fix rbl display
|
||||
* platform: show any container upgrade errors in the UI
|
||||
* users: make remove 2fa separate dialog
|
||||
* mandatory 2fa: show undismissable dialog and warning
|
||||
* restore: validate ipv6 config
|
||||
* location: use the domain where app is installed as default
|
||||
* s3: remove leading slash in CopySource
|
||||
* gcs: fix copy operation
|
||||
* restore: fix crash when trying to mount fs volumes
|
||||
* restore: teardown pseudo backup site
|
||||
* oidc: add separate jwks key route for cloudflare access
|
||||
|
||||
[9.0.15]
|
||||
* sshfs: Use unique temporary ssh key file for each ssh remote operation
|
||||
|
||||
[9.0.16]
|
||||
* Update mongodb to 7.0.28 (also fixes mongobleed)
|
||||
* docker: do not use auth for appstore images
|
||||
* backup: add synology C2
|
||||
* mail: update haraka to 3.1.2
|
||||
* csp/robots: add common patterns
|
||||
|
||||
[9.0.17]
|
||||
* Update mongodb to 7.0.28 (also fixes mongobleed)
|
||||
* UI: add favorites for list views
|
||||
* UI: add collapsible sidebar
|
||||
* docker: do not use auth for appstore images
|
||||
* backup: add synology C2
|
||||
* mail: update haraka to 3.1.2
|
||||
* csp/robots: add common patterns
|
||||
|
||||
[9.0.18]
|
||||
* ami & cloud images: fix setup
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
## Translations
|
||||
|
||||
This documents the convention used for the text in the UI.
|
||||
|
||||
### Tale of Two Cases
|
||||
|
||||
**Title Case**
|
||||
|
||||
All words are capitalized. In title case, articles (a/an/the), conjunctions (and/but/or/...)
|
||||
and prepositions (on/at/...) inside a phrase are not capitalized. Everything else is capitalized
|
||||
- noun, pronoun, verb, adverb.
|
||||
|
||||
Examples:
|
||||
|
||||
* "Sign In to Your Account"
|
||||
* "Terms and Conditions"
|
||||
* "Getting Started with GraphQL"
|
||||
* "Between You and Me"
|
||||
|
||||
**Sentence Case**
|
||||
|
||||
Only first word is capitalized.
|
||||
|
||||
### UI Conventions
|
||||
|
||||
Keeping as much as possible in Sentence Case helps in sharing the same strings.
|
||||
|
||||
| Element | Recommended Style | Example |
|
||||
| -------------- | ---------------------- | -------------------------------- |
|
||||
| Headings | Title Case | Manage Account |
|
||||
| Sub heading | Title Case | Create Admin Account |
|
||||
| Section/Card | Title Case | System Information |
|
||||
| Form Labels | Sentence case | Email address |
|
||||
| Form Groups | Sentence case | Volume mounts, Data directory |
|
||||
| Table headings | Sentence case | Memory limit |
|
||||
| Info sections | Sentence case | Cloudron version |
|
||||
| Buttons | Sentence case | Save changes |
|
||||
| Radio Buttons | Sentence case | Option one / Option two |
|
||||
| Checkbox | Sentence case | Use CIFS encryption |
|
||||
| Menu action | Sentence case | Select all |
|
||||
| Switches | Sentence case | Allow users to edit email |
|
||||
| Descriptions | Sentence case | Enter your password to continue. |
|
||||
| Tooltips | Sentence case | Click to edit. |
|
||||
| Error Messages | Sentence case | Password is too short |
|
||||
| Notifications | Sentence case | Settings saved successfully. |
|
||||
| Legend (graph) | Sentence case | Docker volume, Box data. |
|
||||
| Placeholders | Sentence case | Comma separated IPs or subnets |
|
||||
|
||||
Hints in brackets are small case. Like "(comma separated)".
|
||||
|
||||
### Full Stops
|
||||
|
||||
Sentence fragments like form hints and tooltips (which are always visible) do not need a full stop.
|
||||
All other full sentences do.
|
||||
|
||||
Description has a full stop unless it's a hint/phrase.
|
||||
|
||||
instructional heading in dialogs (like the object being configured) should not have a full stop.
|
||||
|
||||
Switch UI description does not have a fullstop.
|
||||
|
||||
Setting item description does not need a fullstop (usually).
|
||||
|
||||
Checkbox labels do not have a full stop at the end.
|
||||
|
||||
No full stop → short labels, commands, headings, or action text (“Configure Service {{serviceName}}”).
|
||||
|
||||
Full stop → descriptive text or sentences explaining a setting (“The IPv4 address used for DNS A records.”).
|
||||
|
||||
### Dialog Buttons
|
||||
|
||||
'Add' for addition
|
||||
'Cancel' to cancel
|
||||
'Save' for edit/update
|
||||
'Remove' for non-destructive/less destructive things (app password remove)
|
||||
'Delete' for destructive (user delete)
|
||||
|
||||
'Close' - Only for dialogs with the only button
|
||||
|
||||
### Dialog Text
|
||||
|
||||
When asking for confirmation simply ask 'Remove app password "xxx"' . Don't use "really"
|
||||
or other emotional terms. Quote the password/domain name.
|
||||
|
||||
In general, we put just "Delete User" in Title and provide the username in the context.
|
||||
|
||||
Title = action (what you’re doing)
|
||||
Description = context (to whom it applies)
|
||||
|
||||
### Description Text
|
||||
|
||||
| Context | Verb form | Example |
|
||||
| --------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||
| **Action / Button / Instruction** | **Imperative** → “Add” | Button: **Add**, Tooltip: “Add a new link” |
|
||||
| **Section / View description** | **Imperative** → “Add” | Description: **Adds shortcuts to external services on the dashboard.** |
|
||||
|
||||
We use plural when possible. "Admins can ..." , "Operators can ..."
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> OpenID Error</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.errorMessage = `<%- errorMessage %>`;
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
errorMessage: errorMessage,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> OpenID Access Denied</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.submitUrl = '<%- submitUrl %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
submitUrl: submitUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
<title><%= name %> Login</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.note = '<%- note %>';
|
||||
window.cloudron.submitUrl = '<%- submitUrl %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
note: note,
|
||||
submitUrl: submitUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
Generated
+445
-334
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -7,26 +7,26 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.5.1",
|
||||
"@cloudron/pankow": "^3.6.4",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.2",
|
||||
"anser": "^2.3.3",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"marked": "^16.4.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"marked": "^17.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.1.9",
|
||||
"vite": "^7.2.7",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<title><%= name %> Password Reset</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> Login</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.loginUrl = '<%- loginUrl %>';
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron.apiOrigin = `<%= apiOrigin %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
iconUrl: iconUrl,
|
||||
loginUrl: loginUrl,
|
||||
language: language,
|
||||
apiOrigin: apiOrigin
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+526
-1018
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,22 +3,10 @@
|
||||
"rebootDialog": {
|
||||
"title": "本当にサーバーを再起動しますか?"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopyBackupId": "バックアップIDをクリックしてコピー",
|
||||
"clickToCopy": "クリックしてコピー",
|
||||
"copied": "クリップボードにコピーしました"
|
||||
},
|
||||
"action": {
|
||||
"logs": "ログ",
|
||||
"reboot": "再起動"
|
||||
},
|
||||
"table": {
|
||||
"date": "日付"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "次",
|
||||
"prev": "前"
|
||||
},
|
||||
"displayName": "表示名",
|
||||
"username": "ユーザー名",
|
||||
"dialog": {
|
||||
@@ -32,14 +20,7 @@
|
||||
"offline": "Cloudronはオフラインです。再接続中…"
|
||||
},
|
||||
"apps": {
|
||||
"tagsFilterHeaderAll": "タグ一覧",
|
||||
"domainsFilterHeader": "ドメイン一覧",
|
||||
"tagsFilterHeader": "タグ: {{ tags }}",
|
||||
"searchPlaceholder": "アプリを探す",
|
||||
"adminPageActionTooltip": "管理者ページ",
|
||||
"infoActionTooltip": "情報",
|
||||
"logsActionTooltip": "ログ",
|
||||
"configActionTooltip": "設定",
|
||||
"noAccess": {
|
||||
"description": "アクセス権のあるアプリは、ここにに表示されます。",
|
||||
"title": "アプリへのアクセス権がありません。"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,33 +15,11 @@
|
||||
"userManagementNone": "Ta aplikacja posiada własne zarządzanie użytkownikami.",
|
||||
"userManagement": "Zarządanie użytkownikami",
|
||||
"manualWarning": "Manualnie dodaj rekord A dla <b>{{ location }}</b> do publicznego IP tego Cloudrona",
|
||||
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>.",
|
||||
"lowOnResources": "Ten Cloudron jest blisko wyczerpania dostępnych zasobów."
|
||||
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>."
|
||||
},
|
||||
"unstable": "Niestabilne",
|
||||
"appMissing": "Szukasz innej aplikacji? Daj nam znać.",
|
||||
"noAppsFound": "Nie znaleziono żadnych aplikacji.",
|
||||
"searchPlaceholder": "Szukaj alternatyw jak Github, Dropbox, Slack, Trello, ...",
|
||||
"category": {
|
||||
"vpn": "VPN",
|
||||
"wiki": "Wiki",
|
||||
"project": "Zarządzanie projetkami",
|
||||
"sync": "Synchronizacja plików",
|
||||
"learning": "Nauka",
|
||||
"notes": "Notatki",
|
||||
"media": "Media",
|
||||
"git": "Hostowanie kodu",
|
||||
"hosting": "Web Hosting",
|
||||
"game": "Gry",
|
||||
"email": "Email",
|
||||
"finance": "Finanse",
|
||||
"gallery": "Galeria",
|
||||
"forum": "Forum",
|
||||
"crm": "CRM",
|
||||
"document": "Dokumenty",
|
||||
"blog": "Blog",
|
||||
"chat": "Czat",
|
||||
"analytics": "Analityka",
|
||||
"newApps": "Nowe aplikacje",
|
||||
"popular": "Popularne",
|
||||
"all": "Wszystko"
|
||||
@@ -52,26 +30,12 @@
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Zrestartuj teraz",
|
||||
"description": "Restartuj serwer by sfinalizowac instalacje aktualizacji bezpieczeństwa lub w przypadku nieoczekiwanych zachowań. Wszytskie usługi i aplikacje aktywne na tym Cloudronie zostaną automatycznie uruchomione ponownie po restarcie.",
|
||||
"warning": "Restart serwera spowoduje tymczasową niedostepność wszystkich aplikacji zainstalowanych na tym Cloudronie!",
|
||||
"title": "Na pewno zrestartować serwer?"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopyBackupId": "Kliknij by skopiowac Backup ID",
|
||||
"clickToCopy": "Kliknij by skopiować",
|
||||
"copied": "Skopiowano do schowka"
|
||||
},
|
||||
"action": {
|
||||
"logs": "Logi",
|
||||
"reboot": "Restart"
|
||||
},
|
||||
"pagination": {
|
||||
"perPageSelector": "Pokazuj {{ n }} na stronie",
|
||||
"prev": "Poprzednia",
|
||||
"next": "Następna"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
"actions": "Akcje",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"username": "Użytkownik",
|
||||
@@ -86,15 +50,7 @@
|
||||
"offline": "Cloudron jest niedostępny. Odnawiam połączenie…"
|
||||
},
|
||||
"apps": {
|
||||
"domainsFilterHeader": "Wszytskie domeny",
|
||||
"tagsFilterHeaderAll": "Wszystkie tagi",
|
||||
"tagsFilterHeader": "Tagi: {{ tags }}",
|
||||
"stateFilterHeader": "Wszytskie stany",
|
||||
"searchPlaceholder": "Szukaj Aplikacji",
|
||||
"adminPageActionTooltip": "Panel Administratora",
|
||||
"infoActionTooltip": "Informacje",
|
||||
"logsActionTooltip": "Logi",
|
||||
"configActionTooltip": "Konfiguracja",
|
||||
"noAccess": {
|
||||
"description": "Po uzyskaniu dostępu będą one widoczne tutaj.",
|
||||
"title": "Nie masz obecnie dostępu do żadnych aplikacji."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,6 @@
|
||||
"main": {
|
||||
"logout": "නික්මෙන්න",
|
||||
"actions": "ක්රියාමාර්ග",
|
||||
"prettyDate": {
|
||||
"minutesAgo": "විනාඩි {{ m }} ට පෙර",
|
||||
"hoursAgo": "හෝරා {{ h }} ට පෙර",
|
||||
"justNow": "මේ දැන්",
|
||||
"yeserday": "ඊයේ"
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "අවලංගු",
|
||||
"save": "සුරකින්න",
|
||||
@@ -16,13 +10,6 @@
|
||||
"yes": "ඔව්"
|
||||
},
|
||||
"username": "පරිශීලක නාමය",
|
||||
"table": {
|
||||
"date": "දිනය"
|
||||
},
|
||||
"pagination": {
|
||||
"prev": "පෙර",
|
||||
"next": "ඊළඟ"
|
||||
},
|
||||
"searchPlaceholder": "සොයන්න",
|
||||
"multiselect": {
|
||||
"select": "තෝරන්න"
|
||||
@@ -30,35 +17,18 @@
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
"chat": "සම්භාෂණය",
|
||||
"learning": "ඉගෙනීම",
|
||||
"project": "ව්යාපෘති කළමනාකරණය",
|
||||
"all": "සියල්ල",
|
||||
"popular": "ජනප්රිය",
|
||||
"newApps": "නව යෙදුම්",
|
||||
"analytics": "විශ්ලේෂ",
|
||||
"document": "ලේඛන",
|
||||
"crm": "පා.ස.ක. (CRM)",
|
||||
"finance": "මූල්ය",
|
||||
"email": "වි-තැපෑල",
|
||||
"game": "ක්රීඩා",
|
||||
"media": "මාධ්ය",
|
||||
"notes": "සටහන්"
|
||||
"newApps": "නව යෙදුම්"
|
||||
},
|
||||
"title": "යෙදුම් ගබඩාව",
|
||||
"installDialog": {
|
||||
"location": "ස්ථානය",
|
||||
"groups": "සමූහ"
|
||||
},
|
||||
"accountDialog": {
|
||||
"password": "මුරපදය",
|
||||
"email": "වි-තැපෑල"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"title": "මාගේ යෙදුම්",
|
||||
"infoActionTooltip": "තොරතුරු",
|
||||
"searchPlaceholder": "යෙදුම් සොයන්න",
|
||||
"domainsFilterHeader": "සියලුම වසම්"
|
||||
"searchPlaceholder": "යෙදුම් සොයන්න"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cloudron Restore</title>
|
||||
<title>Restore Cloudron</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cloudron Domain Setup</title>
|
||||
<title>Domain Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<title><%= name %> Account Setup</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
+215
-201
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
import { onMounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher, SideBar } from '@cloudron/pankow';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher } from '@cloudron/pankow';
|
||||
import { setLanguage } from './i18n.js';
|
||||
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
|
||||
import { redirectIfNeeded } from './utils.js';
|
||||
@@ -11,7 +15,9 @@ import DashboardModel from './models/DashboardModel.js';
|
||||
import BrandingModel from './models/BrandingModel.js';
|
||||
import Headerbar from './components/Headerbar.vue';
|
||||
import SubscriptionRequiredDialog from './components/SubscriptionRequiredDialog.vue';
|
||||
import RequestErrorDialog from './components/RequestErrorDialog.vue';
|
||||
import OfflineOverlay from './components/OfflineOverlay.vue';
|
||||
import SideBar from './components/SideBar.vue';
|
||||
import AppsView from './views/AppsView.vue';
|
||||
import AppConfigureView from './views/AppConfigureView.vue';
|
||||
import AppearanceView from './views/AppearanceView.vue';
|
||||
@@ -72,11 +78,175 @@ const VIEWS = Object.freeze({
|
||||
VOLUMES: '#/volumes',
|
||||
});
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
const menuItems = ref([{
|
||||
label: t('apps.title'),
|
||||
icon: 'fa fa-grip fa-fw',
|
||||
route: VIEWS.APPS,
|
||||
active: () => view.value === VIEWS.APPS || view.value === VIEWS.APP,
|
||||
}, {
|
||||
label: t('appstore.title'),
|
||||
icon: 'fa fa-cloud-download-alt fa-fw',
|
||||
route: VIEWS.APPSTORE,
|
||||
active: () => view.value === VIEWS.APPSTORE,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
label: t('domains.title'),
|
||||
icon: 'fa fa-globe fa-fw',
|
||||
route: VIEWS.DOMAINS,
|
||||
active: () => view.value === VIEWS.DOMAINS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('users.title'),
|
||||
icon: 'fa fa-users-gear fa-fw',
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
active: () => view.value === VIEWS.APPS || view.value === VIEWS.APP,
|
||||
childItems: [{
|
||||
label: t('main.navbar.users'),
|
||||
icon: 'fa fa-user fa-fw',
|
||||
route: VIEWS.USERS,
|
||||
active: () => view.value === VIEWS.USERS,
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
}, {
|
||||
label: t('main.navbar.groups'),
|
||||
icon: 'fa fa-users fa-fw',
|
||||
route: VIEWS.GROUPS,
|
||||
active: () => view.value === VIEWS.GROUPS,
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
}, {
|
||||
label: 'LDAP',
|
||||
icon: 'fa fa-fw fa-users-rays',
|
||||
route: VIEWS.LDAP,
|
||||
active: () => view.value === VIEWS.LDAP,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: 'OpenID',
|
||||
icon: 'fa fa-fw fa-brands fa-openid',
|
||||
route: VIEWS.OPENID,
|
||||
active: () => view.value === VIEWS.OPENID,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('userdirectory.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.USER_DIRECTORY_SETTINGS,
|
||||
active: () => view.value === VIEWS.USER_DIRECTORY_SETTINGS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}],
|
||||
}, {
|
||||
label: t('emails.title'),
|
||||
icon: 'fa fa-envelope fa-fw',
|
||||
visible: () => profile.value.isAtLeastMailManager,
|
||||
childItems: [{
|
||||
label: 'Domains',
|
||||
icon: 'fa fa-fw fa-globe',
|
||||
route: VIEWS.EMAIL_DOMAINS,
|
||||
active: () => view.value === VIEWS.EMAIL_DOMAINS || view.value === VIEWS.EMAIL_DOMAIN,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('email.incoming.mailboxes.title'),
|
||||
icon: 'fa fa-fw fa-inbox',
|
||||
route: VIEWS.MAILBOXES,
|
||||
active: () => view.value === VIEWS.MAILBOXES,
|
||||
}, {
|
||||
label: t('email.incoming.mailinglists.title'),
|
||||
icon: 'fa fa-fw-solid fa-envelopes-bulk',
|
||||
route: VIEWS.MAILINGLISTS,
|
||||
active: () => view.value === VIEWS.MAILINGLISTS,
|
||||
}, {
|
||||
label: t('emails.eventlog.title'),
|
||||
icon: 'fa fa-fw fa-list-alt',
|
||||
route: VIEWS.EMAIL_EVENTLOG,
|
||||
active: () => view.value === VIEWS.EMAIL_EVENTLOG,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('emails.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.EMAIL_SETTINGS,
|
||||
active: () => view.value === VIEWS.EMAIL_SETTINGS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}]
|
||||
}, {
|
||||
label: t('network.title'),
|
||||
icon: 'fas fa-network-wired fa-fw',
|
||||
route: VIEWS.NETWORK,
|
||||
active: () => view.value === VIEWS.NETWORK,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('volumes.title'),
|
||||
icon: 'fa fa-hdd fa-fw',
|
||||
route: VIEWS.VOLUMES,
|
||||
active: () => view.value === VIEWS.VOLUMES,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('backups.title'),
|
||||
icon: 'fa fa-archive fa-fw',
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
childItems: [{
|
||||
label: t('backups.sites.title'),
|
||||
icon: 'fa fa-fw fa-hard-drive',
|
||||
route: VIEWS.BACKUP_SITES,
|
||||
active: () => view.value === VIEWS.BACKUP_SITES,
|
||||
}, {
|
||||
label: t('backups.archives.title'),
|
||||
icon: 'fa fa-fw fa-grip',
|
||||
route: VIEWS.APP_ARCHIVE,
|
||||
active: () => view.value === VIEWS.APP_ARCHIVE,
|
||||
}]
|
||||
}, {
|
||||
label: t('appearance.title'),
|
||||
icon: 'fa fa-pen-ruler fa-fw',
|
||||
route: VIEWS.APPEARANCE,
|
||||
active: () => view.value === VIEWS.APPEARANCE,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('system.title'),
|
||||
icon: 'fa fa-server fa-fw',
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
childItems: [{
|
||||
label: 'Docker',
|
||||
icon: 'fa-brands fa-fw fa-docker',
|
||||
route: VIEWS.DOCKER,
|
||||
active: () => view.value === VIEWS.DOCKER,
|
||||
}, {
|
||||
label: t('services.title'),
|
||||
icon: 'fa fa-diagram-project fa-fw',
|
||||
route: VIEWS.SERVICES,
|
||||
active: () => view.value === VIEWS.SERVICES,
|
||||
}, {
|
||||
label: t('eventlog.title'),
|
||||
icon: 'fa fa-list-alt fa-fw',
|
||||
route: VIEWS.SYSTEM_EVENTLOG,
|
||||
active: () => view.value === VIEWS.SYSTEM_EVENTLOG,
|
||||
}, {
|
||||
label: t('settings.updates.title'),
|
||||
icon: 'fa fa-fw fa-square-up-right',
|
||||
route: VIEWS.SYSTEM_UPDATE,
|
||||
active: () => view.value === VIEWS.SYSTEM_UPDATE,
|
||||
}, {
|
||||
label: t('system.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.SYSTEM_SETTINGS,
|
||||
active: () => view.value === VIEWS.SYSTEM_SETTINGS,
|
||||
}]
|
||||
}, {
|
||||
separator: true,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('server.title'),
|
||||
icon: 'fa fa-fw fa-microchip',
|
||||
route: VIEWS.SERVER,
|
||||
active: () => view.value === VIEWS.SERVER,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('settings.appstoreAccount.title'),
|
||||
icon: 'fa fa-fw fa-crown',
|
||||
route: VIEWS.CLOUDRON_ACCOUNT,
|
||||
active: () => view.value === VIEWS.CLOUDRON_ACCOUNT,
|
||||
visible: () => profile.value.isAtLeastOwner,
|
||||
}]);
|
||||
|
||||
function onOnline() {
|
||||
ready.value = true;
|
||||
}
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
|
||||
fetcher.globalOptions.errorHook = (error) => {
|
||||
// network error, request killed by browser
|
||||
@@ -104,11 +274,11 @@ const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
|
||||
const sidebar = useTemplateRef('sidebar');
|
||||
const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
|
||||
const ready = ref(false);
|
||||
const view = ref('');
|
||||
const profile = ref({});
|
||||
const dashboardDomain = ref('');
|
||||
const subscription = ref({
|
||||
plan: {},
|
||||
});
|
||||
@@ -116,24 +286,8 @@ const config = ref({});
|
||||
const avatarUrl = ref('');
|
||||
const features = ref({});
|
||||
|
||||
function onSidebarClose() {
|
||||
sidebar.value.close();
|
||||
}
|
||||
|
||||
const SIDEBAR_GROUPS = Object.freeze({
|
||||
BACKUP: 'backup',
|
||||
EMAIL: 'email',
|
||||
SYSTEM: 'system',
|
||||
USERS: 'users'
|
||||
});
|
||||
|
||||
const activeSidebarGroups = ref({});
|
||||
function onToggleGroup(group) {
|
||||
activeSidebarGroups.value[group] = !activeSidebarGroups.value[group];
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const v = location.hash;
|
||||
const v = window.location.hash.split('?')[0];
|
||||
|
||||
if (v === VIEWS.APPS) {
|
||||
view.value = VIEWS.APPS;
|
||||
@@ -212,15 +366,36 @@ ProfileModel.onChange(ProfileModel.KEYS.AVATAR, (value) => {
|
||||
|
||||
async function refreshProfile() {
|
||||
const [error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
profile.value = result;
|
||||
}
|
||||
|
||||
async function refreshConfigAndFeatures() {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
const currentVersion = localStorage.getItem('version');
|
||||
if (currentVersion === null) {
|
||||
localStorage.setItem('version', result.version);
|
||||
} else if (result.version !== currentVersion) {
|
||||
console.log('Dashboard version changed, reloading');
|
||||
localStorage.setItem('version', result.version);
|
||||
window.location.reload(true);
|
||||
}
|
||||
|
||||
config.value = result;
|
||||
features.value = result.features;
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
}
|
||||
|
||||
async function onOnline() {
|
||||
ready.value = true;
|
||||
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
|
||||
}
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 576);
|
||||
function checkForMobile() {
|
||||
isMobile.value = window.innerWidth <= 576;
|
||||
}
|
||||
|
||||
provide('subscriptionRequiredDialog', subscriptionRequiredDialog);
|
||||
@@ -228,10 +403,14 @@ provide('features', features);
|
||||
provide('profile', profile);
|
||||
provide('refreshProfile', refreshProfile);
|
||||
provide('refreshFeatures', refreshConfigAndFeatures);
|
||||
provide('dashboardDomain', dashboardDomain);
|
||||
provide('isMobile', isMobile);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
|
||||
const [error, result] = await provisionModel.status();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
if (redirectIfNeeded(result, 'dashboard')) return; // redirected to some other view...
|
||||
|
||||
@@ -252,16 +431,21 @@ onMounted(async () => {
|
||||
await refreshConfigAndFeatures();
|
||||
|
||||
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
|
||||
if (document.querySelector('link[rel="icon"]')) document.querySelector('link[rel="icon"]').href = `${API_ORIGIN}/api/v1/cloudron/avatar?ts=${Date.now()}`;
|
||||
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.hash = `/${VIEWS.PROFILE}?setup2fa`;
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.href = VIEWS.PROFILE;
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
|
||||
console.log(`Cloudron dashboard v${config.value.version}`);
|
||||
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -269,71 +453,10 @@ onMounted(async () => {
|
||||
<Notification />
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
|
||||
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
|
||||
<RequestErrorDialog/>
|
||||
|
||||
<div v-if="ready" style="display: flex; flex-direction: row; overflow: hidden; height: 100%;">
|
||||
<SideBar v-if="profile.isAtLeastUserManager" ref="sidebar">
|
||||
<a href="#/" class="sidebar-logo" @click="onSidebarClose()">
|
||||
<img :src="avatarUrl" :alt="(config.cloudronName || 'Cloudron') + ' icon'" width="40" height="40"/> {{ config.cloudronName || 'Cloudron' }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPS || view === VIEWS.APP }" :href="VIEWS.APPS" @click="onSidebarClose()"><i class="fa fa-grip fa-fw"></i> {{ $t('apps.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPSTORE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPSTORE" @click="onSidebarClose()"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ $t('appstore.title') }}</a>
|
||||
<hr/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOMAINS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.DOMAINS" @click="onSidebarClose()"><i class="fa fa-globe fa-fw"></i> {{ $t('domains.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastUserManager" @click="onToggleGroup(SIDEBAR_GROUPS.USERS)"><i class="fa fa-users-gear fa-fw"></i> {{ $t('users.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.USERS] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.USERS]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USERS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.USERS" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.GROUPS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.GROUPS" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.LDAP }" :href="VIEWS.LDAP" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> {{ $t('ldap.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.OPENID }" :href="VIEWS.OPENID" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> {{ $t('oidc.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('userdirectory.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup(SIDEBAR_GROUPS.EMAIL)"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.EMAIL] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.EMAIL]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_DOMAINS || view === VIEWS.EMAIL_DOMAIN }" :href="VIEWS.EMAIL_DOMAINS" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILBOXES }" :href="VIEWS.MAILBOXES" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILINGLISTS }" :href="VIEWS.MAILINGLISTS" @click="onSidebarClose()"><i class="fa fa-fw-solid fa-envelopes-bulk"></i> {{ $t('email.incoming.mailinglists.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_EVENTLOG }" :href="VIEWS.EMAIL_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" :href="VIEWS.EMAIL_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('emails.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.NETWORK }" v-show="profile.isAtLeastAdmin" :href="VIEWS.NETWORK" @click="onSidebarClose()"><i class="fas fa-network-wired fa-fw"></i> {{ $t('network.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.VOLUMES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.VOLUMES" @click="onSidebarClose()"><i class="fa fa-hdd fa-fw"></i> {{ $t('volumes.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.BACKUP)"><i class="fa fa-archive fa-fw"></i> {{ $t('backups.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.BACKUP] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.BACKUP]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.BACKUP_SITES }" :href="VIEWS.BACKUP_SITES" @click="onSidebarClose()"><i class="fa fa-fw fa-hard-drive"></i> {{ $t('backups.sites.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APP_ARCHIVE }" :href="VIEWS.APP_ARCHIVE" @click="onSidebarClose()"><i class="fa fa-fw fa-grip"></i> {{ $t('backups.archives.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPEARANCE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPEARANCE" @click="onSidebarClose()"><i class="fa fa-pen-ruler fa-fw"></i> {{ $t('appearance.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.SYSTEM)"><i class="fa fa-server fa-fw"></i> {{ $t('system.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOCKER }" :href="VIEWS.DOCKER" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> {{ $t('docker.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVICES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVICES" @click="onSidebarClose()"><i class="fa fa-diagram-project fa-fw"></i> {{ $t('services.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_EVENTLOG }" :href="VIEWS.SYSTEM_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_UPDATE }" :href="VIEWS.SYSTEM_UPDATE" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_SETTINGS }" :href="VIEWS.SYSTEM_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('system.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<hr v-show="profile.isAtLeastAdmin"/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVER }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVER" @click="onSidebarClose()"><i class="fa fa-microchip fa-fw"></i> {{ $t('server.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.CLOUDRON_ACCOUNT }" v-show="profile.isAtLeastOwner" :href="VIEWS.CLOUDRON_ACCOUNT" @click="onSidebarClose()"><i class="fa fa-crown fa-fw"></i> {{ $t('settings.appstoreAccount.title') }}</a>
|
||||
</div>
|
||||
</SideBar>
|
||||
<SideBar v-if="profile.isAtLeastUserManager" :items="menuItems" :cloudron-name="config.cloudronName" :cloudron-avatar-url="avatarUrl"/>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<Headerbar :config="config" :subscription="subscription"/>
|
||||
@@ -374,112 +497,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.pankow-sidebar {
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
/* width is optimized for english */
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item:hover {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,75 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { FormGroup, Radiobutton, MultiSelect } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
import { ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const props = defineProps([ 'manifest', 'error', 'hideOptionalSsoOption' ]);
|
||||
const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
manifest: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sso: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
installation: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accessRestrictionOption = defineModel('option');
|
||||
const accessRestriction = defineModel('acl');
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
const optionalSso = !!props.manifest.optionalSso;
|
||||
const cloudronAuth = !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']) && !props.hideOptionalSsoOption;
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
const optionalSso = computed(() => {
|
||||
return !!props.manifest.optionalSso && props.installation;
|
||||
});
|
||||
const cloudronAuth = computed(() => {
|
||||
return !(!props.installation && !props.sso) && !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FormGroup v-show="manifest.addons.email">
|
||||
<label>{{ $t('appstore.installDialog.userManagement') }}</label>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.userManagementMailbox') }}
|
||||
<span v-html="$t('appstore.installDialog.configuredForCloudronEmail', { emailDocsLink: 'https://docs.cloudron.io/email/' })"></span>
|
||||
</div>
|
||||
<FormGroup>
|
||||
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
|
||||
<div v-if="!cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
|
||||
<div v-if="manifest.addons.email" v-html="$t('appstore.installDialog.userManagementMailbox')"></div>
|
||||
</FormGroup>
|
||||
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<FormGroup>
|
||||
<label v-show="cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagement') }} <sup><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-show="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
|
||||
|
||||
<label v-show="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-show="!cloudronAuth || manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
|
||||
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
</div>
|
||||
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.dashboardVisibility.description') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED">
|
||||
<div style="margin-left: 20px; display: flex; gap: 10px;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<div>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
</div>
|
||||
|
||||
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED" style="margin-top: 12px; margin-left: 20px; display: flex; gap: 10px;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
|
||||
import { computed, useTemplateRef,ref } from 'vue';
|
||||
import { Menu, Button, ButtonGroup } from '@cloudron/pankow';
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const quickActions = computed(() => {
|
||||
const visibleActions = props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator);
|
||||
if (visibleActions.length <= 2) return visibleActions;
|
||||
|
||||
return visibleActions.filter(a => a.quickAction);
|
||||
});
|
||||
|
||||
const visibleActionCount = computed(() => {
|
||||
return props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator).length;
|
||||
});
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const menuElement = useTemplateRef('menuElement');
|
||||
function onMenu(event) {
|
||||
isMenuOpen.value = true;
|
||||
menuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-bar" :class="{ 'is-menu-open': isMenuOpen }">
|
||||
<Menu ref="menuElement" :model="actions" @close="isMenuOpen = false" />
|
||||
<ButtonGroup class="quick-action-group">
|
||||
<Button tool v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action()" :href="quickAction.href || null" :target="quickAction.target || null" v-tooltip.top="quickAction.label"/>
|
||||
<Button tool @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0 && visibleActionCount !== quickActions.length"/>
|
||||
</ButtonGroup>
|
||||
<Button tool :plain="isMenuOpen ? null : true" secondary @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0" class="menu-action" :class="{ 'hide-on-touch': visibleActionCount === quickActions.length }"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: end;
|
||||
min-height: 31px;
|
||||
align-items: center;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-action-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.action-bar .quick-action-group .pankow-button {
|
||||
background-color: white;
|
||||
color: var(--pankow-color-text);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-bar .quick-action-group .pankow-button:hover {
|
||||
color: var(--pankow-color-primary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.action-bar .quick-action-group .pankow-button {
|
||||
background: var(--pankow-color-background);
|
||||
color: var(--pankow-color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-touch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hide-on-touch {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* cover tables and backupsite view for now */
|
||||
div:hover > div > div > .menu-action,
|
||||
tr:hover .menu-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-action-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* cover tables and backupsite view for now */
|
||||
div:hover > div > div > .quick-action-group,
|
||||
tr:hover .quick-action-group {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,10 +5,11 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, computed, useTemplateRef } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TOKEN_TYPES } from '../constants.js';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import TokensModel from '../models/TokensModel.js';
|
||||
|
||||
@@ -27,6 +28,15 @@ const columns = {
|
||||
label: t('profile.apiTokens.name'),
|
||||
sort: true
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
lastUsedTime: {
|
||||
label: t('profile.apiTokens.lastUsed'),
|
||||
sort(a, b) {
|
||||
@@ -35,36 +45,28 @@ const columns = {
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
actions: {
|
||||
width: '55px',
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(apiToken, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(apiToken) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRevokeToken.bind(null, apiToken),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!tokenName.value) return false;
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) return false;
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiTokens() {
|
||||
const [error, tokens] = await tokensModel.list();
|
||||
@@ -74,7 +76,7 @@ async function refreshApiTokens() {
|
||||
}
|
||||
|
||||
async function onSubmitAddApiToken(){
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
const scope = { '*': tokenScope.value };
|
||||
const allowedIpRanges = tokenAllowedIpRanges.value;
|
||||
@@ -96,15 +98,18 @@ function onReset() {
|
||||
tokenScope.value = 'rw';
|
||||
tokenAllowedIpRanges.value = '';
|
||||
tokenAllowedIpRangesError.value = '';
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function onRevokeToken(apiToken) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('profile.removeApiToken.title', { name: apiToken.name }),
|
||||
title: t('profile.removeApiToken.title'),
|
||||
message: t('profile.removeApiToken.description', { name: apiToken.name }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -123,14 +128,14 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createApiToken.title')"
|
||||
:confirm-label="addedToken ? '' : $t('profile.createApiToken.generateToken')"
|
||||
:confirm-label="addedToken ? '' : $t('main.action.add')"
|
||||
:confirm-active="isFormValid"
|
||||
confirm-style="primary"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitAddApiToken()"
|
||||
@close="onReset()"
|
||||
@@ -138,8 +143,8 @@ onMounted(async () => {
|
||||
<div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedToken">
|
||||
<form @submit.prevent="onSubmitAddApiToken()" autocomplete="off">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<form @submit.prevent="onSubmitAddApiToken()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="apiTokenName">{{ $t('profile.createApiToken.name') }}</label>
|
||||
<TextInput id="apiTokenName" v-model="tokenName" required/>
|
||||
@@ -154,7 +159,8 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('profile.createApiToken.allowedIpRanges') }}</label>
|
||||
<div class="has-error" v-show="tokenAllowedIpRangesError">{{ tokenAllowedIpRangesError }}</div>
|
||||
<TextInput v-model="tokenAllowedIpRanges" :placeholder="$t('profile.apiTokens.allowedIpRangesPlaceholder')" />
|
||||
<TextInput v-model="tokenAllowedIpRanges" />
|
||||
<small class="helper-text">{{ $t('profile.apiTokens.allowedIpRangesPlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
@@ -188,13 +194,11 @@ onMounted(async () => {
|
||||
<span v-else>{{ $t('profile.apiTokens.readonly') }}</span>
|
||||
</template>
|
||||
<template #allowedIpRanges="apiToken">
|
||||
<span v-if="apiToken.allowedIpRanges !== ''" v-tooltip="apiToken.allowedIpRanges">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-if="apiToken.allowedIpRanges !== ''">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-else>{{ '*' }}</span>
|
||||
</template>
|
||||
<template #actions="apiToken">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(apiToken, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(apiToken)" />
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue';
|
||||
import { Dialog, FormGroup, TextInput, PasswordInput, Checkbox } from '@cloudron/pankow';
|
||||
import { s3like } from '../utils.js';
|
||||
import { s3like, mountlike, parseFullBackupPath } from '../utils.js';
|
||||
import BackupProviderForm from './BackupProviderForm.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_WASABI } from '../constants.js';
|
||||
@@ -10,102 +10,132 @@ import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LIN
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const backupConfigInput = useTemplateRef('backupConfigInput');
|
||||
const appId = ref('');
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const providerConfig = ref({});
|
||||
const provider = ref('');
|
||||
const remotePath = ref('');
|
||||
const fullPath = ref('');
|
||||
const format = ref('');
|
||||
const encrypted = ref(false);
|
||||
const encryptionPasswordHint = ref('');
|
||||
const encryptionPassword = ref('');
|
||||
const encryptedFilenames = ref(false);
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
let backupPath = remotePath.value;
|
||||
const backupConfig = {};
|
||||
const config = {};
|
||||
|
||||
const { prefix, remotePath } = parseFullBackupPath(fullPath.value);
|
||||
|
||||
// only set provider specific fields, this will clear them in the db
|
||||
if (s3like(provider.value)) {
|
||||
backupConfig.bucket = providerConfig.value.bucket;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.accessKeyId = providerConfig.value.accessKeyId;
|
||||
backupConfig.secretAccessKey = providerConfig.value.secretAccessKey;
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.accessKeyId = providerConfig.value.accessKeyId;
|
||||
config.secretAccessKey = providerConfig.value.secretAccessKey;
|
||||
config.prefix = prefix;
|
||||
|
||||
if (providerConfig.value.endpoint) backupConfig.endpoint = providerConfig.value.endpoint;
|
||||
if (providerConfig.value.endpoint) config.endpoint = providerConfig.value.endpoint;
|
||||
|
||||
if (provider.value === 's3') {
|
||||
if (providerConfig.value.region) backupConfig.region = providerConfig.value.region;
|
||||
delete backupConfig.endpoint;
|
||||
if (providerConfig.value.region) config.region = providerConfig.value.region;
|
||||
delete config.endpoint;
|
||||
} else if (provider.value === 'minio' || provider.value === 's3-v4-compat') {
|
||||
backupConfig.region = providerConfig.value.region || 'us-east-1';
|
||||
backupConfig.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
|
||||
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
|
||||
config.region = providerConfig.value.region || 'us-east-1';
|
||||
config.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
|
||||
config.s3ForcePathStyle = true; // might want to expose this in the UI
|
||||
} else if (provider.value === 'exoscale-sos') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'wasabi') {
|
||||
backupConfig.region = REGIONS_WASABI.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_WASABI.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'scaleway-objectstorage') {
|
||||
backupConfig.region = REGIONS_SCALEWAY.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_SCALEWAY.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'linode-objectstorage') {
|
||||
backupConfig.region = REGIONS_LINODE.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_LINODE.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'ovh-objectstorage') {
|
||||
backupConfig.region = REGIONS_OVH.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_OVH.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'ionos-objectstorage') {
|
||||
backupConfig.region = REGIONS_IONOS.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_IONOS.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'vultr-objectstorage') {
|
||||
backupConfig.region = REGIONS_VULTR.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_VULTR.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'contabo-objectstorage') {
|
||||
backupConfig.region = REGIONS_CONTABO.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
||||
config.region = REGIONS_CONTABO.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
config.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
||||
} else if (provider.value === 'upcloud-objectstorage') {
|
||||
const m = /^.*\.(.*)\.upcloudobjects.com$/.exec(providerConfig.value.endpoint);
|
||||
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'digitalocean-spaces') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
config.region = 'us-east-1';
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'synology-c2-objectstorage') {
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
config.prefix = prefix;
|
||||
config.noHardlinks = !providerConfig.value.useHardlinks;
|
||||
config.mountOptions = {};
|
||||
|
||||
if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs') {
|
||||
config.mountOptions.host = providerConfig.value.mountOptionHost;
|
||||
config.mountOptions.remoteDir = providerConfig.value.mountOptionRemoteDir;
|
||||
|
||||
if (provider.value === 'cifs') {
|
||||
config.mountOptions.username = providerConfig.value.mountOptionUsername;
|
||||
config.mountOptions.password = providerConfig.value.mountOptionPassword;
|
||||
config.mountOptions.seal = !!providerConfig.value.mountOptionSeal;
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
} else if (provider.value === 'sshfs') {
|
||||
config.mountOptions.user = providerConfig.value.mountOptionUser;
|
||||
config.mountOptions.port = parseInt(providerConfig.value.mountOptionPort);
|
||||
config.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
|
||||
config.preserveAttributes = true;
|
||||
}
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
config.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
|
||||
config.preserveAttributes = true;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
config.mountPoint = providerConfig.value.mountPoint;
|
||||
config.chown = !!providerConfig.value.chown;
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
}
|
||||
} else if (provider.value === 'gcs') {
|
||||
backupConfig.bucket = providerConfig.value.bucket;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.projectId = providerConfig.value.projectId;
|
||||
backupConfig.credentials = providerConfig.value.credentials;
|
||||
} else if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs' || provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
backupConfig.mountOptions = providerConfig.value.mountOptions;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.mountPoint = providerConfig.value.mountPoint;
|
||||
} else if (provider.value === 'filesystem') {
|
||||
const parts = remotePath.value.split('/');
|
||||
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
|
||||
backupConfig.backupDir = parts.join('/'); // this is dirname()
|
||||
config.backupDir = prefix;
|
||||
} else if (provider.value === 'gcs') {
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.projectId = providerConfig.value.projectId;
|
||||
config.credentials = providerConfig.value.credentials;
|
||||
config.prefix = prefix;
|
||||
}
|
||||
|
||||
const data = {
|
||||
format: format.value,
|
||||
provider: provider.value,
|
||||
config: backupConfig,
|
||||
remotePath: backupPath
|
||||
config,
|
||||
remotePath
|
||||
};
|
||||
|
||||
if (encrypted.value) {
|
||||
@@ -166,22 +196,65 @@ function onBackupConfigChanged(event) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(result.target.result); // 'provider', 'config', 'limits', 'format', 'remotePath', 'encrypted', 'encryptedFilenames'
|
||||
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
|
||||
data.remotePath = `${data.config.backupDir}/${data.remotePath}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unable to parse backup config', e);
|
||||
return;
|
||||
}
|
||||
|
||||
provider.value = data.provider;
|
||||
remotePath.value = data.remotePath;
|
||||
providerConfig.value = data.config;
|
||||
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
|
||||
fullPath.value = data.config.prefix ? `${data.config.backupDir}/${data.config.prefix}/${data.remotePath}` : `${data.config.backupDir}/${data.remotePath}`;
|
||||
} else if (data.provider === 'mountpoint') {
|
||||
fullPath.value = data.config.prefix ? `${data.config.mountPoint}/${data.config.prefix}/${data.remotePath}` : `${data.config.mountPoint}/${data.remotePath}`;
|
||||
} else {
|
||||
fullPath.value = data.config.prefix ? `${data.config.prefix}/${data.remotePath}` : data.remotePath;
|
||||
}
|
||||
format.value = data.format;
|
||||
encrypted.value = !!data.encrypted;
|
||||
encryptionPasswordHint.value = data.encryptionPasswordHint || '';
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = data.encryptedFilenames;
|
||||
|
||||
providerConfig.value = {};
|
||||
for (const [key, value] of Object.entries(data.config)) {
|
||||
switch (key) {
|
||||
case 'noHardlinks':
|
||||
case 'chown':
|
||||
case 'preserveAttributes':
|
||||
// not really used for importing
|
||||
break;
|
||||
case 'projectId':
|
||||
case 'credentials':
|
||||
// gcs fields which should be set by user by uploading json
|
||||
break;
|
||||
case 'mountOptions': // providerConfig uses a flattened format of config.mountOptions
|
||||
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
|
||||
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
|
||||
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
|
||||
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
|
||||
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
|
||||
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
|
||||
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
|
||||
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
|
||||
providerConfig.value.mountOptionPrivateKey = '';
|
||||
break;
|
||||
case 'accessKeyId': // s3
|
||||
case 'secretAccessKey': // s3
|
||||
case 'bucket': // s3, gcs
|
||||
case 'prefix': // s3, gcs
|
||||
case 'signatureVersion': // s3
|
||||
case 'endpoint': // s3
|
||||
case 'region': // s3
|
||||
case 'acceptSelfSignedCerts': // s3
|
||||
case 's3ForcePathStyle': // s3
|
||||
providerConfig.value[key] = value;
|
||||
break;
|
||||
default:
|
||||
console.log('unhandled key when importing config file:', key);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
};
|
||||
|
||||
reader.readAsText(event.target.files[0]);
|
||||
@@ -191,6 +264,10 @@ function onUploadBackupConfig() {
|
||||
backupConfigInput.value.click();
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (providerConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
async open(id) {
|
||||
appId.value = id;
|
||||
@@ -198,13 +275,15 @@ defineExpose({
|
||||
formError.value = {};
|
||||
provider.value = '';
|
||||
providerConfig.value = {};
|
||||
remotePath.value = '';
|
||||
fullPath.value = '';
|
||||
encrypted.value = false;
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
encryptionPasswordHint.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,7 +295,7 @@ defineExpose({
|
||||
|
||||
<Dialog ref="dialog" :title="$t('app.importBackupDialog.title')"
|
||||
:confirm-label="$t('app.importBackupDialog.importAction')"
|
||||
:confirm-active="!busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@@ -224,7 +303,10 @@ defineExpose({
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<div>{{ $t('app.importBackupDialog.description') }}</div>
|
||||
<div class="text-danger">{{ $t('app.importBackupDialog.warning') }}</div>
|
||||
|
||||
<!-- ideally, we get can get rid of this and just display this error from the imported config -->
|
||||
<p class="text-danger">{{ $t('app.importBackupDialog.versionMustMatchInfo') }}</p>
|
||||
|
||||
<p>{{ $t('app.importBackupDialog.provideBackupInfo') }}
|
||||
<input type="file" ref="backupConfigFileInput" @change="onBackupConfigChanged" accept="application/json, text/json" style="display:none"/>
|
||||
@@ -233,14 +315,14 @@ defineExpose({
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<!-- remotePath contains the prefix as well -->
|
||||
<FormGroup>
|
||||
<label for="inputRemotePath">{{ $t('app.importBackupDialog.remotePath') }} <sup><a href="https://docs.cloudron.io/backups/#import-app-backup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="inputRemotePath" v-model="remotePath" required />
|
||||
<TextInput id="inputRemotePath" v-model="fullPath" required />
|
||||
</FormGroup>
|
||||
|
||||
<BackupProviderForm ref="form"
|
||||
|
||||
@@ -2,25 +2,31 @@
|
||||
|
||||
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import AppstoreModel from '../models/AppstoreModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const STEP = Object.freeze({
|
||||
LOADING: Symbol('loading'),
|
||||
DETAILS: Symbol('details'),
|
||||
INSTALL: Symbol('install'),
|
||||
});
|
||||
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
|
||||
// reactive
|
||||
const busy = ref(false);
|
||||
@@ -32,7 +38,6 @@ const dialog = useTemplateRef('dialogHandle');
|
||||
const locationInput = useTemplateRef('locationInput');
|
||||
const description = computed(() => marked.parse(manifest.value.description || ''));
|
||||
const domains = ref([]);
|
||||
const dashboardDomain = ref('');
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (!domain.value) return false;
|
||||
@@ -77,6 +82,8 @@ const udpPorts = ref({});
|
||||
const secondaryDomains = ref({});
|
||||
const upstreamUri = ref('');
|
||||
const needsOverwriteDns = ref(false);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
function onDomainChange() {
|
||||
const tmp = domains.value.find(d => d.domain === domain.value);
|
||||
@@ -155,9 +162,8 @@ async function onSubmit(overwriteDns) {
|
||||
formError.value.port = match ? parseInt(match[1]) : null;
|
||||
} else if (error.status === 409 && error.body.message.indexOf('primary location') !== -1) {
|
||||
formError.value.location = error.body.message;
|
||||
} else if (error.status === 412) {
|
||||
formError.value.generic = error.body.message;
|
||||
} else {
|
||||
formError.value.generic = error.body?.message || `Error installing app. Status code: ${error.status} . ${error.body}`;
|
||||
console.error('Failed to install:', error);
|
||||
}
|
||||
}
|
||||
@@ -167,10 +173,14 @@ function onClose() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
});
|
||||
|
||||
const screenshotsContainer = useTemplateRef('screenshotsContainer');
|
||||
@@ -191,10 +201,32 @@ function onScreenshotNext() {
|
||||
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function getApp(id, version = '') {
|
||||
const [error, result] = await appstoreModel.get(id, version);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: async function(a, appCountExceeded, domainList) {
|
||||
open: async function(appId, version, appCountExceeded, domainList) {
|
||||
busy.value = false;
|
||||
step.value = STEP.DETAILS;
|
||||
step.value = STEP.LOADING;
|
||||
formError.value = {};
|
||||
|
||||
// give it some time to fetch before showing loading
|
||||
const openTimer = setTimeout(dialog.value.open, 200);
|
||||
|
||||
const a = await getApp(appId, version);
|
||||
if (!a) {
|
||||
clearTimeout(openTimer);
|
||||
dialog.value.close();
|
||||
throw new Error('app not found');
|
||||
}
|
||||
|
||||
app.value = a;
|
||||
appMaxCountExceeded.value = appCountExceeded;
|
||||
manifest.value = a.manifest;
|
||||
@@ -212,7 +244,7 @@ defineExpose({
|
||||
domains.value = domainList;
|
||||
|
||||
// preselect with dashboard domain
|
||||
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).domain;
|
||||
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
|
||||
|
||||
tcpPorts.value = a.manifest.tcpPorts;
|
||||
udpPorts.value = a.manifest.udpPorts;
|
||||
@@ -231,12 +263,11 @@ defineExpose({
|
||||
for (const p in secondaryDomains.value) {
|
||||
const port = secondaryDomains.value[p];
|
||||
port.value = port.defaultValue;
|
||||
port.domain = domains.value[0].domain;
|
||||
port.domain = dashboardDomain.value;
|
||||
}
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
|
||||
dialog.value.open();
|
||||
step.value = STEP.DETAILS;
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
@@ -246,8 +277,11 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="true" style="width: unset; min-width: min(450px, 95%)">
|
||||
<div class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="step !== STEP.LOADING" style="width: unset;" :style="{ 'min-width': step !== STEP.LOADING ? 'min(450px, 95%)' : 'unset' }">
|
||||
<div v-if="step === STEP.LOADING" class="app-install-dialog-body">
|
||||
<Spinner class="pankow-spinner-large"/>
|
||||
</div>
|
||||
<div v-else class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<div class="app-install-header">
|
||||
<div class="summary" v-if="app.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="app.iconUrl" />{{ manifest.title }}</div>
|
||||
@@ -304,7 +338,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest" :users="users" :groups="groups" :installation="true"/>
|
||||
|
||||
<div class="bottom-button-bar">
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
|
||||
@@ -5,9 +5,10 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import AppPasswordsModel from '../models/AppPasswordsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -29,7 +30,7 @@ const columns = {
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
label: t('main.table.created'),
|
||||
hideMobile: true,
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
@@ -40,16 +41,12 @@ const columns = {
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(appPassword, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(appPassword) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRemove.bind(null, appPassword),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
// new dialog props
|
||||
@@ -77,22 +74,23 @@ async function refresh() {
|
||||
passwords.value = result;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
setTimeout(() => {
|
||||
passwordName.value = '';
|
||||
identifier.value = '';
|
||||
addedPassword.value = '';
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!passwordName.value) return false;
|
||||
if (!identifier.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
addedPassword.value = '';
|
||||
|
||||
@@ -108,10 +106,12 @@ async function onSubmit() {
|
||||
|
||||
async function onRemove(appPassword) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('profile.removeAppPassword.title', { name: appPassword.name }),
|
||||
title: t('profile.removeAppPassword.title'),
|
||||
message: t('profile.removeAppPassword.description', { name: appPassword.name }),
|
||||
confirmLabel: t('main.action.remove'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -156,14 +156,14 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-label="addedPassword ? '' : $t('profile.createAppPassword.generatePassword')"
|
||||
:confirm-active="addedPassword || isFormValid"
|
||||
:confirm-label="addedPassword ? '' : $t('main.action.add')"
|
||||
confirm-style="primary"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
@close="onReset()"
|
||||
@@ -171,8 +171,8 @@ onMounted(async () => {
|
||||
<div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedPassword">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
|
||||
<TextInput id="passwordName" v-model="passwordName" required/>
|
||||
@@ -180,7 +180,7 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.createAppPassword.app') }}</label>
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" />
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
@@ -207,9 +207,7 @@ onMounted(async () => {
|
||||
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
|
||||
<template #actions="password">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(password, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(password)" />
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// for restore from archive or clone !
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef, computed, inject } from 'vue';
|
||||
import { InputGroup, FormGroup, TextInput, SingleSelect, Dialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import PortBindings from '../components/PortBindings.vue';
|
||||
@@ -14,6 +14,7 @@ const appsModel = AppsModel.create();
|
||||
const archivesModel = ArchivesModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const appId = ref(null);
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const restoreArchive = ref({});
|
||||
@@ -119,7 +120,7 @@ defineExpose({
|
||||
|
||||
const app = archive.appConfig || {
|
||||
subdomain: '',
|
||||
domain: domains.value[0].domain,
|
||||
domain: dashboardDomain.value,
|
||||
secondaryDomains: [],
|
||||
portBindings: {}
|
||||
}; // pre-8.2 backups do not have appConfig
|
||||
@@ -129,7 +130,7 @@ defineExpose({
|
||||
|
||||
restoreLocation.value = app.subdomain;
|
||||
const d = domains.value.find(function (d) { return app.domain === d.domain; });
|
||||
restoreDomain.value = d ? d.domain : domains.value[0].domain; // try to pre-select the app's domain
|
||||
restoreDomain.value = d ? d.domain : dashboardDomain.value; // try to pre-select the app's domain
|
||||
restoreSecondaryDomains.value = {};
|
||||
needsOverwrite.value = false;
|
||||
restoreArchive.value = archive;
|
||||
@@ -190,7 +191,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="appId ? $t('app.cloneDialog.title', { app: fqdn }) : $t('backups.restoreArchiveDialog.title')"
|
||||
:title="appId ? $t('app.cloneDialog.title') : $t('backups.restoreArchiveDialog.title')"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
|
||||
@@ -38,26 +38,29 @@ const accessRestriction = ref({
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (busy.value) return false;
|
||||
if (!upstreamUri.value) return false;
|
||||
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
let iconFile = 'src';
|
||||
function onIconChanged(file) {
|
||||
iconFile = file;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
|
||||
const data = {
|
||||
@@ -98,8 +101,9 @@ async function onRemove() {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: `Really remove applink?`,
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -133,6 +137,8 @@ defineExpose({
|
||||
groups.value = result;
|
||||
|
||||
applinkDialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,17 +151,17 @@ defineExpose({
|
||||
alternate-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-active="isValid"
|
||||
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
@alternate="onRemove()"
|
||||
>
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!isValid" />
|
||||
<input style="display: none;" type="submit" />
|
||||
|
||||
<p class="has-error" v-show="error.generic">{{ error.generic }}</p>
|
||||
|
||||
@@ -172,12 +178,13 @@ defineExpose({
|
||||
|
||||
<div>
|
||||
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" size="512" display-height="80px" style="width: 80px"/>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" :size="512" display-height="80px" style="width: 80px"/>
|
||||
</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="applinkTags">{{ $t('app.display.tags') }}</label>
|
||||
<TagInput id="applinkTags" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
|
||||
<TagInput id="applinkTags" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
|
||||
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { inject, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup } from '@cloudron/pankow';
|
||||
import ApplinkDialog from './ApplinkDialog.vue';
|
||||
import Section from './Section.vue';
|
||||
import SettingsItem from './SettingsItem.vue';
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
|
||||
function onAddExternalLink() {
|
||||
applinkDialog.value.open();
|
||||
}
|
||||
|
||||
function onApplinkAdded() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
|
||||
|
||||
<Section :title="$t('dashboard.title')">
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('externallinks.label') }}</label>
|
||||
<div>{{ $t('externallinks.description') }}</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; position: relative; align-items: center">
|
||||
<Button tool plain @click="onAddExternalLink()" :disabled="!features.branding">{{ $t('main.action.add') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ClipboardAction, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupsModel = BackupsModel.create();
|
||||
|
||||
const busy = ref(true);
|
||||
|
||||
const backupContentTableColumns = {
|
||||
label: {
|
||||
label: t('backups.listing.contents'),
|
||||
sort: true,
|
||||
},
|
||||
fileCount: {
|
||||
label: t('backup.target.fileCount'),
|
||||
sort(a, b, A, B) {
|
||||
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort(a, b, A , B) {
|
||||
return A.stats?.upload?.size - B.stats?.upload?.size;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const backup = ref({ contents: [], validStats: false });
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
defineExpose({
|
||||
async open(b) {
|
||||
backup.value = JSON.parse(JSON.stringify(b)); // make a copy
|
||||
backup.value.contents = [];
|
||||
backup.value.validStats = false; // old cloudron version had invalid stats
|
||||
busy.value = true;
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
if (backup.value.type === 'app') {
|
||||
backup.value.validStats = backup.value.stats?.upload && backup.value.stats?.copy;
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// amend detailed app info
|
||||
const appsById = {};
|
||||
|
||||
const [appsError, apps] = await appsModel.list();
|
||||
if (appsError) console.error('Failed to get apps list:', appsError);
|
||||
|
||||
(apps || []).forEach(function (app) {
|
||||
appsById[app.id] = app;
|
||||
});
|
||||
|
||||
for (const contentId of backup.value.dependsOn) {
|
||||
const match = contentId.match(/(mail|app)_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) continue;
|
||||
const [error, result] = await backupsModel.get(contentId);
|
||||
if (error) console.error(error);
|
||||
const content = { id: null, label: null, fqdn: null, stats: null };
|
||||
content.stats = result.stats;
|
||||
if (match[1] === 'mail') {
|
||||
content.id = 'mail';
|
||||
content.label = 'Mail Server';
|
||||
} else {
|
||||
const app = appsById[match[2]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
} else { // uninstalled app
|
||||
content.id = match[2];
|
||||
}
|
||||
}
|
||||
backup.value.contents.push(content);
|
||||
}
|
||||
|
||||
backup.value.validStats = backup.value.stats?.aggregatedUpload && backup.value.stats?.aggregatedCopy;
|
||||
busy.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ backup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
|
||||
<div class="info-value">{{ backup.label || 'Not set'}}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
<div class="info-value">
|
||||
<div>
|
||||
{{ backup.remotePath }}
|
||||
<ClipboardAction plain :value="backup.remotePath"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(backup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ backup.packageVersion }}</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="backup.validStats">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s) | {{ backup.appCount }} app(s) </div>
|
||||
<div v-else class="info-value">{{ prettyFileSize(backup.stats.upload.size) }} | {{ backup.stats.upload.fileCount }} file(s)</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="backup.validStats">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyDuration(backup.stats.aggregatedUpload.duration + backup.stats.aggregatedCopy.duration) }}</div>
|
||||
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 15px 0" v-if="backup.type === 'box'"/>
|
||||
|
||||
<div v-if="backup.type === 'box'">
|
||||
<TableView :columns="backupContentTableColumns" :model="backup.contents" :busy="busy">
|
||||
<template #label="content">
|
||||
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
|
||||
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
|
||||
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
|
||||
</template>
|
||||
<template #fileCount="content">
|
||||
<div v-if="content.stats?.upload" style="text-align: right">{{ content.stats.upload.fileCount }}</div>
|
||||
<div v-else style="text-align: right">-</div>
|
||||
</template>
|
||||
<!-- <td>{{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }}</td> -->
|
||||
<template #size="content">
|
||||
<div v-if="content.stats?.upload" style="text-align: right">{{ prettyFileSize(content.stats.upload.size) }}</div>
|
||||
<div v-else style="text-align: right">-</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, onMounted, watch, watchEffect } from 'vue';
|
||||
import { Button, InputGroup, SingleSelect, FormGroup, TextInput, Checkbox, PasswordInput, NumberInput } from '@cloudron/pankow';
|
||||
import { BACKUP_FORMATS, STORAGE_PROVIDERS, REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_EXOSCALE, REGIONS_DIGITALOCEAN, REGIONS_HETZNER, REGIONS_WASABI, REGIONS_S3 } from '../constants.js';
|
||||
import ProvisionModel from '../models/ProvisionModel.js';
|
||||
@@ -92,6 +92,10 @@ watch(provider, (newProvider) => {
|
||||
if (parseInt(providerConfig.value.downloadConcurrency) < 30) providerConfig.value.downloadConcurrency = 30;
|
||||
if (parseInt(providerConfig.value.syncConcurrency) < 20) providerConfig.value.syncConcurrency = 20;
|
||||
if (parseInt(providerConfig.value.copyConcurrency) < 500) providerConfig.value.downloadConcurrency = 500;
|
||||
} else if (newProvider === 'cifs') {
|
||||
providerConfig.value.mountOptionSeal = true;
|
||||
} else if (newProvider === 'sshfs') {
|
||||
providerConfig.value.mountOptionPort = 23;
|
||||
} else if (newProvider === 'gcs') {
|
||||
providerConfig.value.credentials = {
|
||||
client_email: '',
|
||||
@@ -100,6 +104,17 @@ watch(provider, (newProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(format, (newFormat) => {
|
||||
if (newFormat === 'rsync') {
|
||||
if (provider.value === 'filesystem' || mountlike(provider.value)) providerConfig.value.useHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (!providerConfig.value.mountOptionPrivateKey) return;
|
||||
providerConfig.value.mountOptionPrivateKey = providerConfig.value.mountOptionPrivateKey.replaceAll('\\n', '\n');
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await getBlockDevices();
|
||||
});
|
||||
@@ -113,25 +128,25 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('backups.configureBackupStorage.provider') }} <sup><a href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" />
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- mountpoint -->
|
||||
<FormGroup v-if="provider === 'mountpoint'">
|
||||
<label for="mountPointInput">{{ $t('backups.configureBackupStorage.mountPoint') }}</label>
|
||||
<TextInput id="mountPointInput" v-model="providerConfig.mountPoint" placeholder="/mnt/backups" required/>
|
||||
<div v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></div>
|
||||
<small class="helper-text" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }} ({{ provider }})</label>
|
||||
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }}</label>
|
||||
<TextInput id="mountOptionHostInput" v-model="providerConfig.mountOptionHost" placeholder="Server IP or hostname" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }} ({{ provider }})</label>
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }}</label>
|
||||
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
|
||||
</FormGroup>
|
||||
|
||||
@@ -140,13 +155,13 @@ onMounted(async () => {
|
||||
|
||||
<!-- CIFS -->
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
|
||||
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }}</label>
|
||||
<TextInput id="mountOptionUsernameInput" v-model="providerConfig.mountOptionUsername" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS -->
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
|
||||
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }}</label>
|
||||
<PasswordInput id="mountOptionPasswordInput" v-model="providerConfig.mountOptionPassword" required />
|
||||
</FormGroup>
|
||||
|
||||
@@ -178,19 +193,19 @@ onMounted(async () => {
|
||||
<!-- SSHFS -->
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
|
||||
<textarea id="mountOptionPrivateKeyInput" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
|
||||
<textarea id="mountOptionPrivateKeyInput" rows="7" style="white-space: nowrap;" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Filesystem -->
|
||||
<FormGroup v-if="provider === 'filesystem' && !importOnly">
|
||||
<label for="backupDirInput">{{ $t('backups.configureBackupStorage.localDirectory') }}</label>
|
||||
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="Directory for backups" required />
|
||||
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="/opt/backups" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- S3/Minio/SOS/GCS/UpCloud/B2/R2 -->
|
||||
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
|
||||
<!-- Endpoint - S3/Minio/SOS/GCS/UpCloud/B2/R2/C2 -->
|
||||
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2' || provider === 'synology-c2-objectstorage'">
|
||||
<label for="endpointInput">{{ $t('backups.configureBackupStorage.s3Endpoint') }}</label>
|
||||
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="URL" required />
|
||||
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="https://s3endpoint.example.com" required />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="provider === 'minio' || provider === 's3-v4-compat'" v-model="providerConfig.acceptSelfSignedCerts" :label="$t('backups.configureBackupStorage.acceptSelfSignedCerts')"/>
|
||||
@@ -200,12 +215,14 @@ onMounted(async () => {
|
||||
<TextInput id="bucketInput" v-model="providerConfig.bucket" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- when importing/restoring, the user enters a fullPath which contains the prefix -->
|
||||
<FormGroup v-if="provider !== 'filesystem' && !importOnly">
|
||||
<label for="prefixInput">{{ $t('backups.configureBackupStorage.prefix') }}</label>
|
||||
<TextInput id="prefixInput" v-model="providerConfig.prefix" placeholder="Prefix for backup file names" />
|
||||
<TextInput id="prefixInput" v-model="providerConfig.prefix" placeholder="my-backups" />
|
||||
<small class="helper-text">{{ $t('backups.configureBackupStorage.prefixHelperText') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- S3/Minio/SOS/GCS -->
|
||||
<!-- Region Selector -->
|
||||
<FormGroup v-if="
|
||||
provider === 's3' ||
|
||||
provider === 'digitalocean-spaces' ||
|
||||
@@ -236,7 +253,8 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup v-if="provider === 's3-v4-compat'">
|
||||
<label for="s3v4CompatRegionInput">{{ $t('backups.configureBackupStorage.region') }}</label>
|
||||
<TextInput id="s3v4CompatRegionInput" v-model="providerConfig.region" placeholder="Leave empty to use us-east-1 as default" />
|
||||
<TextInput id="s3v4CompatRegionInput" v-model="providerConfig.region" />
|
||||
<small class="helper-text">{{ $t('backups.configureBackupStorage.regionHelperText') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
@@ -253,7 +271,8 @@ onMounted(async () => {
|
||||
<input type="file" id="gcsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcsKeyChange"/>
|
||||
<label for="gcsKeyInput">{{ $t('backups.configureBackupStorage.gcsServiceKey') }}{{ providerConfig.projectId ? ` - project: ${providerConfig.projectId}` : '' }}</label>
|
||||
<InputGroup>
|
||||
<TextInput readonly required style="flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service Account Key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
<input style="display: none" :value="providerConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service account key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-show="gcsFileParseError">{{ gcsFileParseError }}</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ const backupSitesModel = BackupSitesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const step = ref('storage');
|
||||
const newSiteId = ref('');
|
||||
const name = ref('');
|
||||
@@ -29,7 +28,7 @@ const formError = ref({});
|
||||
const busy = ref(false);
|
||||
const enableForUpdates = ref(false);
|
||||
const provider = ref('');
|
||||
const includeExclude = ref('everything'); // or exclude, include
|
||||
const includeExclude = ref(''); // or exclude, include
|
||||
const contentOptions = ref([]);
|
||||
const contentInclude = ref([]);
|
||||
const contentExclude = ref([]);
|
||||
@@ -101,6 +100,9 @@ async function onSubmit() {
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
data.region = 'us-east-1';
|
||||
data.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'synology-c2-objectstorage') {
|
||||
data.region = 'us-east-1';
|
||||
data.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
data.prefix = providerConfig.value.prefix;
|
||||
@@ -227,6 +229,12 @@ function onCancel() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
step.value = 'storage';
|
||||
@@ -247,7 +255,7 @@ defineExpose({
|
||||
encryptionPasswordHint.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
limits.value = {};
|
||||
includeExclude.value = 'everything';
|
||||
includeExclude.value = '';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
@@ -282,6 +290,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -291,7 +301,7 @@ defineExpose({
|
||||
<Dialog ref="dialog" :title="$t('backups.site.addDialog.title')">
|
||||
<div>
|
||||
<div v-if="step === 'storage'">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
@@ -306,10 +316,10 @@ defineExpose({
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div description>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div>
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')" required/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')" required/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')" required/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
@@ -370,7 +380,7 @@ defineExpose({
|
||||
|
||||
<div style="display: flex; gap: 6px; align-items: end;">
|
||||
<Button secondary v-if="!busy" :disabled="busy" @click="onCancel()">{{ $t('main.dialog.cancel') }}</Button>
|
||||
<Button primary :disabled="busy" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
<Button primary :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike, regionName } from '../utils.js';
|
||||
import { s3like, mountlike, prettySiteLocation } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -38,6 +38,11 @@ const useHardlinks = ref(false);
|
||||
const chown = ref(false);
|
||||
const preserveAttributes = ref(false);
|
||||
|
||||
watch(mountOptionsPrivateKey, () => {
|
||||
if (!mountOptionsPrivateKey.value) return;
|
||||
mountOptionsPrivateKey.value = mountOptionsPrivateKey.value.replaceAll('\\n', '\n');
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
@@ -200,15 +205,7 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.provider && site.config">
|
||||
<label><i v-if="site.encrypted" class="fa-solid fa-lock"></i> Storage: <b>{{ site.provider }} ({{ site.format }}) </b></label>
|
||||
<div>
|
||||
<span v-if="site.provider === 'filesystem'">{{ site.config.backupDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'disk' || site.provider === 'ext4' || site.provider === 'xfs' || site.provider === 'mountpoint'">{{ site.config.mountOptions.diskPath || site.config.mountPoint }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'sshfs'">{{ site.config.mountOptions.host }}:{{ site.config.mountOptions.remoteDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 's3'">{{ site.config.region + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'minio'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'gcs'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else>{{ regionName(site.provider, site.config.endpoint) + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
</div>
|
||||
<div>{{ prettySiteLocation(site) }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
@@ -249,13 +246,13 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
|
||||
<p class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</p>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</div>
|
||||
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
|
||||
<datalist id="uploadPartSizeTicks">
|
||||
<option :value="1024*1024*10"></option>
|
||||
@@ -269,21 +266,19 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync'">
|
||||
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
|
||||
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
|
||||
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
|
||||
<span v-show="provider === 'digitalocean-spaces'">{{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }}</span>
|
||||
</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}</div>
|
||||
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -35,8 +35,20 @@ async function onSubmit() {
|
||||
if (includeExclude.value === 'everything') {
|
||||
contents = null;
|
||||
} else if (includeExclude.value === 'exclude') {
|
||||
if (contentExclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Exclude at least one content item or select Everything';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { exclude: contentExclude.value };
|
||||
} else if (includeExclude.value === 'include' && contentInclude.value.length) {
|
||||
} else if (includeExclude.value === 'include') {
|
||||
if (contentInclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Include at least one content item';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { include: contentInclude.value };
|
||||
}
|
||||
|
||||
@@ -60,6 +72,9 @@ defineExpose({
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
provider.value = t.provider;
|
||||
includeExclude.value = 'everything';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
|
||||
@@ -68,7 +83,7 @@ defineExpose({
|
||||
|
||||
contentOptions.value = [{
|
||||
id: 'box',
|
||||
label: 'Platform',
|
||||
label: 'System & email',
|
||||
}];
|
||||
|
||||
result.forEach(a => {
|
||||
@@ -86,8 +101,6 @@ defineExpose({
|
||||
includeExclude.value = 'include';
|
||||
contentInclude.value = t.contents.include;
|
||||
}
|
||||
} else {
|
||||
includeExclude.value = 'everything';
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
@@ -109,21 +122,24 @@ defineExpose({
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<p>{{ $t('backups.configureBackupStorage.backupContents.context', { name: site.name }) }}</p>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div class="error-label" v-if="formError.includeExclude">{{ formError.includeExclude }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
+29
-30
@@ -1,24 +1,24 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Checkbox, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
import { cronDays, cronHours, parseSchedule } from '../utils.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
|
||||
const id = ref('');
|
||||
const site = ref({});
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const scheduleEnabled = ref(false);
|
||||
const scheduleType = ref('');
|
||||
const days = ref([]);
|
||||
const hours = ref([]);
|
||||
const configureRetention = ref(''); // this is 'name' and not 'id' of backupRetentions because SingleSelect needs strings
|
||||
const isConfigureValid = computed(() => {
|
||||
return !!days.value.length && !!hours.value.length;
|
||||
return scheduleType.value === 'never' || (days.value.length > 0 && hours.value.length > 0);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -27,7 +27,7 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
let schedule;
|
||||
if (scheduleEnabled.value) {
|
||||
if (scheduleType.value === 'pattern') {
|
||||
let daysPattern;
|
||||
if (days.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = days.value;
|
||||
@@ -41,7 +41,7 @@ async function onSubmit() {
|
||||
schedule = 'never';
|
||||
}
|
||||
|
||||
let [error] = await backupSitesModel.setSchedule(id.value, schedule);
|
||||
let [error] = await backupSitesModel.setSchedule(site.value.id, schedule);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
@@ -49,7 +49,7 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return x.name === configureRetention.value; });
|
||||
[error] = await backupSitesModel.setRetention(id.value, selectedRetention.id);
|
||||
[error] = await backupSitesModel.setRetention(site.value.id, selectedRetention.id);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
@@ -63,29 +63,24 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(site) {
|
||||
id.value = site.id;
|
||||
async open(s) {
|
||||
site.value = s;
|
||||
busy.value = false;
|
||||
formError.value = false;
|
||||
days.value = [];
|
||||
hours.value = [];
|
||||
|
||||
const currentRetentionString = JSON.stringify(site.retention);
|
||||
const currentRetentionString = JSON.stringify(site.value.retention);
|
||||
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
|
||||
configureRetention.value = selectedRetention ? selectedRetention.name : BackupSitesModel.backupRetentions[0].name;
|
||||
|
||||
if (site.schedule === 'never') {
|
||||
scheduleEnabled.value = false;
|
||||
if (site.value.schedule === 'never') {
|
||||
scheduleType.value = 'never';
|
||||
} else {
|
||||
scheduleEnabled.value = true;
|
||||
|
||||
const tmp = site.schedule.split(' ');
|
||||
const tmpHours = tmp[2].split(',');
|
||||
const tmpDays = tmp[5].split(',');
|
||||
|
||||
if (tmpDays[0] === '*') days.value = cronDays.map((day) => { return day.id; });
|
||||
else days.value = tmpDays.map((day) => { return parseInt(day, 10); });
|
||||
|
||||
if (tmpHours[0] === '*') hours.value = cronHours.map(h => h.id);
|
||||
else hours.value = tmpHours.map((hour) => { return parseInt(hour, 10); });
|
||||
scheduleType.value = 'pattern';
|
||||
const result = parseSchedule(site.value.schedule);
|
||||
days.value = result.days; // Array of cronDays.id
|
||||
hours.value = result.hours; // Array of cronHours.id
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
@@ -105,18 +100,22 @@ defineExpose({
|
||||
:confirm-active="isConfigureValid"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p>{{ $t('backups.configureBackupSchedule.schedule.context', { name: site.name }) }}</p>
|
||||
|
||||
<div class="error-label" v-show="formError">{{ formError }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule') }}</label>
|
||||
<div v-html="$t('backups.configureBackupSchedule.scheduleDescription')"></div>
|
||||
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule.title') }}</label>
|
||||
<div description v-html="$t('backups.configureBackupSchedule.schedule.description')"></div>
|
||||
|
||||
<Checkbox :label="$t('main.statusEnabled')" v-model="scheduleEnabled" />
|
||||
<div v-if="scheduleEnabled" style="display: flex; gap: 10px; margin-left: 10px">
|
||||
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" :disabled="!scheduleEnabled" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect :disabled="!scheduleEnabled" v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<Radiobutton v-model="scheduleType" value="never" :label="$t('backups.configureBackupSchedule.disable')"/>
|
||||
<Radiobutton v-model="scheduleType" value="pattern" :label="$t('backups.configureBackupSchedule.enable')"/>
|
||||
<div v-if="scheduleType === 'pattern'" style="display: flex; align-items: center; gap: 10px; margin: 10px">
|
||||
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div class="text-small text-danger" v-show="scheduleType === 'pattern' && !(hours.length !== 0 && days.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function onNameSave(newName) {
|
||||
|
||||
const [error] = await brandingModel.setName(newName);
|
||||
savingName.value = false;
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
name.value = newName;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ onMounted(async () => {
|
||||
<div style="display: flex; justify-content: space-around; margin-bottom: 20px;">
|
||||
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
|
||||
<label>{{ $t('branding.logo') }}</label>
|
||||
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding"/>
|
||||
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding" fallback-src="/api/v1/cloudron/avatar"/>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
|
||||
@@ -87,7 +87,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<SettingsItem>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave"/>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave" :maxlength="64"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
|
||||
@@ -130,7 +130,7 @@ defineExpose({ updateDomains: selectCurrentDomain });
|
||||
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
<div v-if="lastTask.active" style="padding: 0 10px">
|
||||
<div v-if="lastTask.active">
|
||||
<ProgressBar :value="lastTask.percent" :busy="true" />
|
||||
<div>{{ lastTask.message }}</div>
|
||||
</div>
|
||||
|
||||
+13
-11
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -13,14 +13,14 @@ const formError = ref({});
|
||||
const busy = ref (false);
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!password.value) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -51,6 +51,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,15 +70,15 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;">
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.disable2FA.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -22,7 +22,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Section :title="$t('system.diskUsage.title')">
|
||||
<div class="filesystems-grid">
|
||||
<div>
|
||||
<DiskUsageItem v-for="filesystem in filesystems" :key="filesystem.filesystem" :filesystem="filesystem" />
|
||||
</div>
|
||||
</Section>
|
||||
@@ -30,21 +30,6 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.filesystems-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: 300ms;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.filesystems-grid {
|
||||
grid-template-columns: 1fr; /* Single column on small screens */
|
||||
}
|
||||
}
|
||||
|
||||
.usage-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@@ -1,59 +1,29 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Button, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import VolumesModel from '../models/VolumesModel.js';
|
||||
import { prettyDecimalSize, prettyDate } from '@cloudron/pankow/utils';
|
||||
import { getColor } from '../utils.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const volumesModel = VolumesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const props = defineProps({
|
||||
filesystem: Object
|
||||
});
|
||||
|
||||
function hue(numOfSteps, step) {
|
||||
const deg = 360/numOfSteps;
|
||||
return `hsl(${deg*step} 70% 50%)`;
|
||||
}
|
||||
|
||||
let colorIndex = 0;
|
||||
let colors = [];
|
||||
function resetColors(n) {
|
||||
colorIndex = 7;
|
||||
colors = [];
|
||||
for (let i = 0; i < n; i++) colors.push(hue(n, i));
|
||||
}
|
||||
|
||||
function getNextColor() {
|
||||
return colors[colorIndex++];
|
||||
}
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const percent = ref(0);
|
||||
const contents = ref([]);
|
||||
const speed = ref(-1);
|
||||
const contents = ref([]); // cached
|
||||
const speed = ref(-1); // cached
|
||||
const ts = ref(0); // cached
|
||||
const highlight = ref(null);
|
||||
const showingCachedValue = ref(false);
|
||||
|
||||
let eventSource = null;
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const appsById = {};
|
||||
result.forEach(a => { appsById[a.id] = a; });
|
||||
|
||||
[error, result] = await volumesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const volumesById = {};
|
||||
result.forEach(v => { volumesById[v.id] = v; });
|
||||
|
||||
[error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
async function getUsage() {
|
||||
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
|
||||
contents.value = [];
|
||||
@@ -65,23 +35,17 @@ async function refresh() {
|
||||
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
ts.value = Date.now();
|
||||
showingCachedValue.value = false;
|
||||
|
||||
// we first 8 colors are reserved for known system contents
|
||||
resetColors(contents.value.length + 8);
|
||||
contents.value.forEach(content => {
|
||||
// assign fixed colors for known entries
|
||||
if (content.id === 'platformdata') content.color = colors[0];
|
||||
else if (content.id === 'boxdata') content.color = colors[1];
|
||||
else if (content.id === 'maildata') content.color = colors[2];
|
||||
else if (content.id === 'cloudron-backup-default') content.color = colors[3];
|
||||
else if (content.id === 'docker') content.color = colors[4];
|
||||
else if (content.id === 'docker-volumes') content.color = colors[5];
|
||||
else if (content.id === '/apps.swap') content.color = colors[6];
|
||||
else if (content.id === 'os') content.color = colors[7];
|
||||
else content.color = getNextColor();
|
||||
});
|
||||
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
|
||||
contents.value.sort((a, b) => b.usage - a.usage);
|
||||
|
||||
const raw = localStorage.getItem('diskUsageCache');
|
||||
const cache = raw ? JSON.parse(raw) : {};
|
||||
cache[props.filesystem.filesystem] = { contents: contents.value, speed: speed.value, ts: ts.value };
|
||||
localStorage.setItem('diskUsageCache', JSON.stringify(cache));
|
||||
|
||||
eventSource.close();
|
||||
} else if (payload.type === 'progress') {
|
||||
percent.value = payload.percent;
|
||||
@@ -89,16 +53,8 @@ async function refresh() {
|
||||
if (payload.speed) {
|
||||
speed.value = payload.speed;
|
||||
} else if (payload.content) {
|
||||
if (payload.content.type === 'app') {
|
||||
payload.content.app = appsById[payload.content.id];
|
||||
if (!payload.content.app) payload.content.uninstalled = true;
|
||||
else payload.content.label = payload.content.app.label || payload.content.app.fqdn;
|
||||
} else if (payload.content.type === 'volume') {
|
||||
payload.content.volume = volumesById[payload.content.id];
|
||||
payload.content.label = payload.content.volume ? `Volume ${payload.content.volume.name}` : 'Removed volume';
|
||||
} else {
|
||||
payload.content.label = payload.content.id;
|
||||
}
|
||||
// this can happen if more than one backup sites for filesystem share the folder, so avoid negativ values here
|
||||
if (payload.content.usage < 0) payload.content.usage = 0;
|
||||
contents.value.push(payload.content);
|
||||
} else {
|
||||
console.error('Unkown data', payload);
|
||||
@@ -117,9 +73,33 @@ async function onExpand() {
|
||||
|
||||
isExpanded.value = true;
|
||||
|
||||
refresh();
|
||||
getUsage();
|
||||
}
|
||||
|
||||
function loadFromCache() {
|
||||
const raw = localStorage.getItem('diskUsageCache');
|
||||
const cache = raw ? JSON.parse(raw) : {};
|
||||
const entry = cache[props.filesystem.filesystem];
|
||||
|
||||
if (!entry) return;
|
||||
|
||||
if (Date.now() - entry.ts < 60 * 60 * 1000) { // 1 hour old
|
||||
contents.value = entry.contents;
|
||||
speed.value = entry.speed;
|
||||
percent.value = 100;
|
||||
ts.value = entry.ts;
|
||||
isExpanded.value = true;
|
||||
showingCachedValue.value = true;
|
||||
} else {
|
||||
delete cache[props.filesystem.filesystem]; // remove obsolete entry
|
||||
localStorage.setItem('diskUsageCache', JSON.stringify(cache));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFromCache();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) eventSource.close();
|
||||
});
|
||||
@@ -130,25 +110,31 @@ onUnmounted(() => {
|
||||
<div class="disk-item">
|
||||
<div class="disk-item-title">
|
||||
<div>{{ filesystem.mountpoint }} <small class="text-muted">{{ filesystem.type }}</small></div>
|
||||
<Button v-if="isExpanded" small tool plain icon="fa-solid fa-rotate" :disabled="percent < 100" :loading="percent < 100" @click="refresh()"/>
|
||||
<Button v-if="isExpanded" small tool plain icon="fa-solid fa-rotate" :disabled="percent < 100" :loading="percent < 100" @click="getUsage()"/>
|
||||
</div>
|
||||
<div class="disk-item-size-and-speed">
|
||||
<div>{{ prettyDecimalSize(filesystem.available) }} free of {{ prettyDecimalSize(filesystem.size) }} total</div>
|
||||
<div>{{ prettyDecimalSize(filesystem.used) }} used of {{ prettyDecimalSize(filesystem.size) }} total
|
||||
<span v-if="showingCachedValue">(Last updated {{ prettyDate(ts) }})</span>
|
||||
</div>
|
||||
<div v-if="speed !== -1">I/O Rate: {{ prettyDecimalSize(speed * 1000 * 1000) }}/sec</div>
|
||||
</div>
|
||||
<div v-if="isExpanded" @mouseout="highlight = null">
|
||||
<ProgressBar v-if="percent < 100" mode="indeterminate" :show-label="false"/>
|
||||
<div v-else class="disk-size" style="overflow: visible;">
|
||||
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.id" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
|
||||
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.name" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="percent < 100" style="text-align: center; margin-top: 10px;">Calculating speed and disk usage ... {{ parseInt(percent) }}%</div>
|
||||
<div v-else>
|
||||
<table style="width: 100%">
|
||||
<table style="width: 100%;table-layout: fixed">
|
||||
<tr v-for="content in contents" :key="content.id" @mouseover="highlight = content.id" :class="{ highlight: highlight === content.id }">
|
||||
<td style="width: 20px"><div class="content-color-indicator" :style="{ backgroundColor: content.color }"></div></td>
|
||||
<td>{{ content.label }}</td>
|
||||
<td style="text-align: right">{{ prettyDecimalSize(content.usage) }}</td>
|
||||
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
<a v-if="content.type === 'app'" :href="`/#/app/${content.id}/info`">{{ content.name }}</a>
|
||||
<a v-else-if="content.type === 'volume'" href="/#/volumes">{{ content.name }} (Volume)</a>
|
||||
<span v-else>{{ content.name }}</span>
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">{{ prettyDecimalSize(content.usage) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -173,6 +159,7 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background-color: var(--card-background);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disk-item:focus,
|
||||
|
||||
@@ -5,7 +5,8 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, inject } from 'vue';
|
||||
import { Button, Menu, TableView, InputDialog } from '@cloudron/pankow';
|
||||
import { Button, TableView, InputDialog } from '@cloudron/pankow';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import DockerRegistryDialog from '../components/DockerRegistryDialog.vue';
|
||||
import DockerRegistriesModel from '../models/DockerRegistriesModel.js';
|
||||
@@ -28,25 +29,23 @@ const columns = {
|
||||
label: t('dockerRegistries.username'),
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
actions: {
|
||||
width: '100px',
|
||||
}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(registry, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(registry) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-pencil-alt',
|
||||
label: t('main.action.edit'),
|
||||
quickAction: true,
|
||||
action: onEditOrAdd.bind(null, registry),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
quickAction: true,
|
||||
action: onRemove.bind(null, registry),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const features = inject('features');
|
||||
@@ -62,11 +61,12 @@ function onEditOrAdd(registry = null) {
|
||||
|
||||
async function onRemove(registry) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
title: t('dockerRegistries.removeDialog.title', { serverAddress: registry.serverAddress}),
|
||||
message: t('dockerRegistres.removeDialog.description'),
|
||||
title: t('dockerRegistries.removeDialog.title'),
|
||||
message: t('dockerRegistres.removeDialog.description', { serverAddress: registry.serverAddress }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.delete'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -93,7 +93,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Section :title="$t('dockerRegistries.title')" :title-badge="!features.privateDockerRegistry ? 'Upgrade' : ''">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
<DockerRegistryDialog ref="dialog" @success="refresh()"/>
|
||||
|
||||
@@ -106,9 +105,7 @@ onMounted(async () => {
|
||||
|
||||
<TableView :columns="columns" :model="registries" :busy="busy" :placeholder="$t('dockerRegistries.emptyPlaceholder')">
|
||||
<template #actions="registry">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(registry, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(registry)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -18,7 +18,6 @@ const providers = [
|
||||
{ name: 'Google Cloud', value: 'google-cloud' },
|
||||
{ name: 'Linode', value: 'linode' },
|
||||
{ name: 'Quay', value: 'quay' },
|
||||
{ name: 'Treescale', value: 'treescale' },
|
||||
{ name: t('settings.registryConfig.providerOther') || 'Other', value: 'other' },
|
||||
];
|
||||
|
||||
@@ -38,7 +37,7 @@ const password = ref('');
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -83,8 +82,8 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('dockerRegistries.dialog.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:title="registry ? $t('dockerRegistries.dialog.editTitle') : $t('dockerRegistries.dialog.addTitle')"
|
||||
:confirm-label="registry ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
@@ -113,7 +112,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="emailInput">{{ $t('dockerRegistries.email') }} (Optional)</label>
|
||||
<label for="emailInput">{{ $t('dockerRegistries.email') }} (optional)</label>
|
||||
<TextInput id="emailInput" v-model="email" />
|
||||
</FormGroup>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Checkbox, Button } from '@cloudron/pankow';
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Button } from '@cloudron/pankow';
|
||||
import { getTextFromFile } from '../utils.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import DomainProviderForm from './DomainProviderForm.vue';
|
||||
@@ -31,7 +31,7 @@ const dnsConfig = ref(DomainsModel.createEmptyConfig());
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -99,6 +99,10 @@ function onKeyFileChange() {
|
||||
keyFileName.value = file ? file.name : '';
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (dnsConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open(d) {
|
||||
d = d ? JSON.parse(JSON.stringify(d)) : { config: {}, tlsConfig: { provider: 'letsencrypt-prod', wildcard: true } }; // make a copy
|
||||
@@ -131,10 +135,10 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="editing ? $t('domains.domainDialog.editTitle', { domain: domain }) : $t('domains.domainDialog.addTitle')"
|
||||
:title="editing ? $t('domains.domainDialog.editTitle') : $t('domains.domainDialog.addTitle')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-label="editing ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@@ -148,7 +152,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="domainInput">{{ $t('domains.domainDialog.domain') }}</label>
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing ? true : undefined" required />
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing" :required="!editing" />
|
||||
</FormGroup>
|
||||
|
||||
<DomainProviderForm v-model:provider="provider" v-model:dns-config="dnsConfig" v-model:tls-provider="tlsProvider" v-model:zone-name="zoneName" v-model:custom-nameservers="customNameservers" :domain="domain" :show-advanced="showAdvanced" />
|
||||
@@ -156,14 +160,14 @@ defineExpose({
|
||||
<div v-show="showAdvanced">
|
||||
<div v-if="tlsProvider === 'fallback'">
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
|
||||
<p v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></p>
|
||||
<div description v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="tlsProvider === 'fallback'">
|
||||
<input type="file" ref="certificateFileInput" style="display: none" @change="onCertificateFileChange"/>
|
||||
<input type="file" ref="keyFileInput" style="display: none" @change="onKeyFileChange"/>
|
||||
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px">
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-top: 15px">
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertCertificatePlaceholder') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="certificateFileName" @click="certificateFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { TextInput, InputGroup, MaskedInput, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
|
||||
import { ENDPOINTS_OVH } from '../constants.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -53,15 +53,6 @@ function needsPort80(dnsProvider, tlsProvider) {
|
||||
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
|
||||
}
|
||||
|
||||
function setDefaultTlsProvider(p) {
|
||||
// wildcard LE won't work without automated DNS
|
||||
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
|
||||
tlsProvider.value = 'letsencrypt-prod';
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}
|
||||
|
||||
function resetFields() {
|
||||
dnsConfig.value.accessKeyId = '';
|
||||
dnsConfig.value.accessKey = '';
|
||||
@@ -86,10 +77,16 @@ function resetFields() {
|
||||
dnsConfig.value.username = '';
|
||||
}
|
||||
|
||||
function onProviderChange(p) {
|
||||
setDefaultTlsProvider(p);
|
||||
resetFields(p);
|
||||
}
|
||||
watch(provider, (p) => {
|
||||
resetFields();
|
||||
|
||||
// wildcard LE won't work without automated DNS
|
||||
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
|
||||
tlsProvider.value = 'letsencrypt-prod';
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const gcdnsFileParseError = ref('');
|
||||
function onGcdnsFileInputChange(event) {
|
||||
@@ -130,9 +127,13 @@ function onGcdnsFileInputChange(event) {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('domains.domainDialog.provider') }} <sup><a href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="provider" @select="onProviderChange" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
|
||||
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
<!-- Route53 -->
|
||||
<FormGroup v-if="provider === 'route53'">
|
||||
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
|
||||
@@ -148,7 +149,8 @@ function onGcdnsFileInputChange(event) {
|
||||
<input type="file" id="gcdnsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcdnsFileInputChange"/>
|
||||
<label class="control-label">{{ $t('domains.domainDialog.gcdnsServiceAccountKey') }}{{ dnsConfig.projectId ? ` - project: ${dnsConfig.projectId}` : '' }}</label>
|
||||
<InputGroup>
|
||||
<TextInput readonly required style="flex-grow: 1" v-model="dnsConfig.credentials.client_email" placeholder="Service Account Key" onclick="getElementById('gcdnsKeyFileInput').click();"/>
|
||||
<input style="display: none" :value="dnsConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; flex-grow: 1" v-model="dnsConfig.credentials.client_email" placeholder="Service account key" onclick="getElementById('gcdnsKeyFileInput').click();"/>
|
||||
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcdnsKeyFileInput').click();"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-show="gcdnsFileParseError">{{ gcdnsFileParseError }}</div>
|
||||
@@ -259,7 +261,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
|
||||
<!-- Hetzner -->
|
||||
<FormGroup v-if="provider === 'hetzner'">
|
||||
<FormGroup v-if="provider === 'hetzner' || provider === 'hetznercloud'">
|
||||
<label for="hetznerTokenInput">{{ $t('domains.domainDialog.hetznerToken') }}</label>
|
||||
<MaskedInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
@@ -310,19 +312,16 @@ function onGcdnsFileInputChange(event) {
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label for="zoneNameInput">{{ $t('domains.domainDialog.zoneName') }} <sup><a href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="zoneNameInput" v-model="zoneName" />
|
||||
<small class="helper-text">{{ $t('domains.domainDialog.zoneNamePlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
<Checkbox v-if="showAdvanced" v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
|
||||
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label>Certificate Provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
|
||||
<label>Certificate provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,10 +9,12 @@ const props = defineProps({
|
||||
helpUrl: { type: String, required: false },
|
||||
value: { type: String, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false },
|
||||
multiline: { type: Boolean, default: false },
|
||||
markdown: { type: Boolean, default: false },
|
||||
rows: { type: Number, default: 2 },
|
||||
maxlength: { type: Number, default: -1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
@@ -41,6 +43,7 @@ function startEdit() {
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.required && !draftValue.value) return;
|
||||
emit('save', draftValue.value);
|
||||
}
|
||||
|
||||
@@ -54,13 +57,13 @@ function cancel() {
|
||||
<FormGroup>
|
||||
<label>{{ label }} <sup v-if="helpUrl"><a :href="helpUrl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="editing" style="display: flex; align-items: center; gap: 6px">
|
||||
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving"></textarea>
|
||||
<Button tool @click="save" :disabled="saving">{{ $t('main.dialog.save') }}</Button>
|
||||
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"></textarea>
|
||||
<Button tool @click="save" :disabled="saving || (required && !draftValue)">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button tool plain secondary @click="cancel" :disabled="saving">{{ $t('main.dialog.cancel') }}</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="markdown" v-html="marked.parse(value)"></div>
|
||||
<div v-if="markdown" v-html="marked.parseInline(value)"></div>
|
||||
<div v-else>{{ value }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -49,7 +49,7 @@ const autoCreate = ref(false);
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -258,7 +258,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy" v-if="provider !== 'noop'">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isFormValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.url }">
|
||||
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
|
||||
|
||||
+17
-12
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import { isValidEmail } from '@cloudron/pankow/utils';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -15,15 +15,18 @@ const busy = ref (false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (email.value && !isValidEmail(email.value)) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (!isValidEmail(email.value)) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -56,6 +59,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,21 +78,21 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.email">
|
||||
<label>{{ $t('profile.changeEmail.email') }}</label>
|
||||
<EmailInput v-model="email" />
|
||||
<EmailInput v-model="email" required/>
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changeEmail.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required/>
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -113,15 +113,16 @@ onMounted(async () => {
|
||||
@confirm="onBlocklistSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p class="small">{{ $t('network.firewall.configure.description') }}</p>
|
||||
|
||||
<div class="small">{{ $t('network.firewall.configure.description') }}</div>
|
||||
<br/>
|
||||
<form novalidate @submit.prevent="onBlocklistSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBlocklistBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBlocklistBusy || !isBlocklistValid"/>
|
||||
<FormGroup>
|
||||
<label for="blocklistInput">{{ $t('network.firewall.blockedIpRanges') }}</label>
|
||||
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
|
||||
<div class="has-error" v-show="editBlocklistError">{{ editBlocklistError }}</div>
|
||||
<textarea id="blocklistInput" v-model="editBlocklist" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
|
||||
<textarea id="blocklistInput" v-model="editBlocklist" rows="4"></textarea>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -138,15 +139,16 @@ onMounted(async () => {
|
||||
@confirm="onTrustedIpsSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p class="small">{{ $t('network.trustedIps.description') }}</p>
|
||||
|
||||
<div class="small">{{ $t('network.trustedIps.description') }}</div>
|
||||
<br/>
|
||||
<form novalidate @submit.prevent="onTrustedIpsSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editTrustedIpsBusy">
|
||||
<input style="display: none;" type="submit" :disabled="editTrustedIpsBusy || !isTrustedIpsValid"/>
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('network.trustedIpRanges') }}</label>
|
||||
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
|
||||
<div class="has-error" v-show="editTrustedIpsError">{{ editTrustedIpsError }}</div>
|
||||
<textarea v-model="editTrustedIps" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
|
||||
<textarea v-model="editTrustedIps" rows="4"></textarea>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -64,7 +64,7 @@ const uploadMenuModel = [{
|
||||
action: onUploadFile,
|
||||
}, {
|
||||
icon: 'fa-regular fa-folder-open',
|
||||
label: t('filemanager.toolbar.newFolder'),
|
||||
label: t('filemanager.toolbar.uploadFolder'),
|
||||
action: onUploadFolder,
|
||||
}];
|
||||
|
||||
@@ -109,9 +109,10 @@ async function onNewFile() {
|
||||
message: t('filemanager.newFileDialog.title'),
|
||||
value: '',
|
||||
required: true,
|
||||
confirmStyle: 'success',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('filemanager.newFileDialog.create'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!newFileName) return;
|
||||
@@ -125,9 +126,10 @@ async function onNewFolder() {
|
||||
message: t('filemanager.newDirectoryDialog.title'),
|
||||
value: '',
|
||||
required: true,
|
||||
confirmStyle: 'success',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('filemanager.newFileDialog.create'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!newFolderName) return;
|
||||
@@ -239,8 +241,9 @@ async function deleteHandler(files) {
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.removeDialog.reallyDelete'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no'),
|
||||
confirmLabel: t('main.dialog.delete'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
@@ -369,9 +372,9 @@ async function onRestartApp() {
|
||||
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no'),
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
@@ -443,7 +446,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
appLink.value = `https://${result.body.fqdn}`;
|
||||
title.value = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
|
||||
title.value = `${result.body.label || result.body.fqdn} ` + (result.body.manifest ? `(${result.body.manifest.title})` : '');
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
|
||||
@@ -40,17 +40,27 @@ function renderTooltip(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body, labelColors } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
// datapoints are in sync with the indexing of body
|
||||
const { title, body, labelColors, dataPoints } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
if (body) {
|
||||
const titleLines = title || [];
|
||||
const bodyLines = body.map(item => item.lines);
|
||||
const bodyLines = body.map(item => { return { label: item.lines }; });
|
||||
|
||||
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</div>`;
|
||||
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = labelColors[i];
|
||||
innerHtml += `<div style="color: ${colors.borderColor}" class="graphs-tooltip-item">${body}</div>`;
|
||||
// first amend the value so we know the dataPoints index, then sort and render
|
||||
bodyLines.forEach((body, i) => {
|
||||
body.value = dataPoints[i].parsed?.y || 0;
|
||||
body.color = labelColors[i].borderColor;
|
||||
});
|
||||
bodyLines.sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
});
|
||||
bodyLines.slice(0, 5).forEach(body => {
|
||||
innerHtml += `<div style="color: ${body.color}" class="graphs-tooltip-item">${body.label}</div>`;
|
||||
});
|
||||
|
||||
if (bodyLines.length > 5) innerHtml += '<div class="graphs-tooltip-item graphs-tooltip-ellipsis">⋯</div>';
|
||||
|
||||
tooltipElem.value.innerHTML = innerHtml;
|
||||
}
|
||||
@@ -200,8 +210,10 @@ function pruneGraphData(dataset, options) {
|
||||
}
|
||||
|
||||
function advance() {
|
||||
graph.options.scales.x.min += LIVE_REFRESH_INTERVAL_MSECS;
|
||||
graph.options.scales.x.max += LIVE_REFRESH_INTERVAL_MSECS;
|
||||
// advance is called in a timer and when the browser tab is in the background , it is unreliable. Use absolute time to set the scale
|
||||
const now = Date.now();
|
||||
graph.options.scales.x.min = now - 5*60*1000;
|
||||
graph.options.scales.x.max = now;
|
||||
graph.update('none');
|
||||
}
|
||||
|
||||
@@ -338,7 +350,7 @@ defineExpose({
|
||||
.graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -367,8 +379,33 @@ defineExpose({
|
||||
border-right: 1px var(--pankow-color-primary) solid;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item {
|
||||
padding: 2px 0px;
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
background: var(--pankow-color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.graphs-tooltip-title {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item:last-of-type {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-ellipsis {
|
||||
font-size: 9px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -19,9 +19,9 @@ const group = ref(null);
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const name = ref('');
|
||||
const users = ref([]);
|
||||
const userIds = ref([]);
|
||||
const allUsers = ref([]);
|
||||
const apps = ref([]);
|
||||
const appIds = ref([]);
|
||||
const allApps = ref([]);
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -29,7 +29,7 @@ async function onSubmit() {
|
||||
formError.value = {};
|
||||
|
||||
if (group.value) {
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -37,7 +37,7 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
} else {
|
||||
const [error] = await groupsModel.add(name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.add(name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -63,13 +63,13 @@ defineExpose({
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.label = (u.username || u.email));
|
||||
allUsers.value = result;
|
||||
users.value = g ? g.userIds : [];
|
||||
userIds.value = g ? g.userIds : [];
|
||||
|
||||
[error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(a => a.label = (a.label || a.fqdn));
|
||||
allApps.value = result;
|
||||
apps.value = g ? g.appIds : [];
|
||||
appIds.value = g ? g.appIds : [];
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
@@ -79,7 +79,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="group ? $t('users.editGroupDialog.title', { name: group.name }) : $t('users.addGroupDialog.title')"
|
||||
:title="group ? $t('users.editGroupDialog.title') : $t('users.addGroupDialog.title')"
|
||||
:confirm-label="group ? $t('main.dialog.save') : $t('users.group.addGroupAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== ''"
|
||||
@@ -103,13 +103,14 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="usersInput">{{ $t('users.group.users') }}</label>
|
||||
<div v-if="group?.source"><span v-for="user of groupEdit.selectedUsers" :key="user.id"> {{ (user.username || user.email) }}</span></div>
|
||||
<MultiSelect v-else v-model="users" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
<!-- membership of external groups cannot be edited -->
|
||||
<div v-if="group?.source"><span v-for="userId of userIds" :key="userId" style="padding-right: 5px">{{ allUsers.find(u => u.id === userId)?.username || allUsers.find(u => u.id === userId)[userId]?.email }}</span></div>
|
||||
<MultiSelect v-else v-model="userIds" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="appsInput">Access to Apps</label>
|
||||
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
|
||||
<MultiSelect v-model="appIds" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -7,12 +7,13 @@ const t = i18n.t;
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { eachLimit } from 'async';
|
||||
import { Button, Popover, Icon, Spinner } from '@cloudron/pankow';
|
||||
import { Menu, Button, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import NotificationsModel from '../models/NotificationsModel.js';
|
||||
import ServicesModel from '../models/ServicesModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const props = defineProps(['config', 'subscription']);
|
||||
defineProps(['config', 'subscription']);
|
||||
|
||||
const profile = inject('profile');
|
||||
|
||||
@@ -24,6 +25,7 @@ function onOpenHelp(popover, event, elem) {
|
||||
}
|
||||
|
||||
const servicesModel = ServicesModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const notificationButton = useTemplateRef('notificationButton');
|
||||
@@ -33,6 +35,9 @@ const notificationsAllBusy = ref(false);
|
||||
|
||||
function onOpenNotifications(popover, event, elem) {
|
||||
popover.open(event, elem);
|
||||
|
||||
// close after 2 seconds if there is nothing to show
|
||||
if (notifications.value.length === 0) setTimeout(popover.close, 2000);
|
||||
}
|
||||
|
||||
async function onMarkNotificationRead(notification) {
|
||||
@@ -41,12 +46,15 @@ async function onMarkNotificationRead(notification) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refresh();
|
||||
|
||||
// close after 2 seconds if there is nothing to show
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function onMarkAllNotificationRead() {
|
||||
notificationsAllBusy.value = true;
|
||||
|
||||
await eachLimit(notifications.value, 2, async (notification) => {
|
||||
await eachLimit(notifications.value, 5, async (notification) => {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
if (error) return console.error(error);
|
||||
@@ -55,6 +63,8 @@ async function onMarkAllNotificationRead() {
|
||||
await refresh();
|
||||
|
||||
notificationsAllBusy.value = false;
|
||||
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -77,7 +87,7 @@ function onSubscriptionRequired() {
|
||||
|
||||
const platformStatus = ref({
|
||||
message: '',
|
||||
isReady: true,
|
||||
state: '',
|
||||
});
|
||||
|
||||
let platformTimeoutId = 0;
|
||||
@@ -87,7 +97,16 @@ async function trackPlatformStatus() {
|
||||
|
||||
platformStatus.value = result;
|
||||
|
||||
if (!result.isReady) platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
|
||||
if (result.state === 'starting') platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
function onShowPlatformError() {
|
||||
inputDialog.value.info({
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
title: t('main.platform.startupFailed'),
|
||||
message: platformStatus.value.message,
|
||||
});
|
||||
}
|
||||
|
||||
const description = marked.parse(t('support.help.description', {
|
||||
@@ -97,6 +116,23 @@ const description = marked.parse(t('support.help.description', {
|
||||
apiLink: 'https://docs.cloudron.io/api.html'
|
||||
}));
|
||||
|
||||
const avatarActions = [{//
|
||||
icon: 'fa-solid fa-circle-user',
|
||||
label: t('profile.title'),
|
||||
action: () => { window.location.href = '#/profile'; }
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-right-from-bracket',
|
||||
label: t('main.logout'),
|
||||
action: () => { profileModel.logout(); }
|
||||
}];
|
||||
|
||||
const avatarMenu = useTemplateRef('avatarMenu');
|
||||
function onAvatarClick(event) {
|
||||
avatarMenu.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profile.value.isAtLeastAdmin) await refresh();
|
||||
|
||||
@@ -111,6 +147,9 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="headerbar">
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<Menu ref="avatarMenu" :model="avatarActions" />
|
||||
|
||||
<Popover ref="notificationPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<div v-if="notifications.length" style="overflow: auto; margin-bottom: 10px">
|
||||
@@ -150,17 +189,20 @@ onUnmounted(() => {
|
||||
|
||||
<div style="flex-grow: 1;"></div>
|
||||
|
||||
<div v-if="!platformStatus.isReady" class="headerbar-info">
|
||||
<Spinner style="margin-right: 10px"/> {{ platformStatus.message }}
|
||||
<div v-if="platformStatus.state === 'starting'" class="headerbar-info">
|
||||
<Spinner style="margin-right: 10px"/>{{ platformStatus.message }}
|
||||
</div>
|
||||
<div v-else-if="platformStatus.state === 'failed'" class="headerbar-info text-danger" style="cursor: pointer" @click="onShowPlatformError">
|
||||
<Icon :icon="'fa fa-exclamation-triangle'"/> {{ $t('main.platform.startupFailed') }}
|
||||
</div>
|
||||
|
||||
<!-- Warnings if subscription is expired or unpaid -->
|
||||
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
|
||||
|
||||
<div class="headerbar-action" style="gap: 6px" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
|
||||
<div class="headerbar-action pankow-no-mobile" style="gap: 6px" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||
<div class="headerbar-action" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
|
||||
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
|
||||
<a class="headerbar-action" href="#/profile"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,13 +217,14 @@ onUnmounted(() => {
|
||||
|
||||
.headerbar-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
padding: 4px 15px;
|
||||
}
|
||||
|
||||
.headerbar-action {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-text-color);
|
||||
|
||||
@@ -10,8 +10,8 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'editable', required: true },
|
||||
src: { type: String, required: true },
|
||||
fallbackSrc: { type: String, required: true },
|
||||
size: { type: String, required: true },
|
||||
maxSize: { type: String, required: false },
|
||||
size: { type: Number, required: false, default: 512 },
|
||||
maxSize: { type: Number, required: false, default: 0 },
|
||||
displayHeight: { type: String, required: false },
|
||||
displayWidth: { type: String, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
@@ -109,22 +109,19 @@ function onChanged(event) {
|
||||
fr.onload = function () {
|
||||
const image = new Image();
|
||||
image.onload = function () {
|
||||
const size = props.size ? parseInt(props.size) : 512;
|
||||
const maxSize = props.maxSize ? parseInt(props.maxSize) : 0;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
if (maxSize) {
|
||||
if (image.naturalWidth > maxSize) {
|
||||
canvas.width = maxSize;
|
||||
canvas.height = (image.naturalHeight / image.naturalWidth) * maxSize;
|
||||
if (props.maxSize) {
|
||||
if (image.naturalWidth > props.maxSize) {
|
||||
canvas.width = props.maxSize;
|
||||
canvas.height = (image.naturalHeight / image.naturalWidth) * props.maxSize;
|
||||
} else {
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
}
|
||||
} else {
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
canvas.width = props.size;
|
||||
canvas.height = props.size;
|
||||
}
|
||||
|
||||
const imageDimensionRatio = image.width / image.height;
|
||||
@@ -155,8 +152,7 @@ function onChanged(event) {
|
||||
internalSrc.value = canvas.toDataURL('image/png');
|
||||
isChanged.value = true;
|
||||
|
||||
console.log('internalSrc is now some data url');
|
||||
emit('changed', file);
|
||||
emit('changed', dataURLtoFile(internalSrc.value, 'image.png'));
|
||||
};
|
||||
|
||||
image.src = fr.result;
|
||||
@@ -177,7 +173,6 @@ function onError() {
|
||||
|
||||
<div ref="image" :disabled="disabled || null" class="image-picker" @click="!disabled && onEdit()">
|
||||
<img :src="internalSrc || src" @error="onError" class="image-picker-image" :style="{ height: displayHeight || null, width: displayWidth || null }">
|
||||
|
||||
<!-- Editable mode -->
|
||||
<template v-if="mode === 'editable'">
|
||||
<div v-if="isChanged" class="image-picker-actions" style="visibility: visible;">
|
||||
|
||||
@@ -56,16 +56,17 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('users.setGhostDialog.title', { username: user.username })"
|
||||
:title="$t('users.setGhostDialog.title')"
|
||||
:reject-label="success ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="success ? '' : $t('users.setGhostDialog.setPassword')"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p>{{ $t('users.setGhostDialog.context', { username: user.username }) }}</p>
|
||||
<p>{{ $t('users.setGhostDialog.description') }}</p>
|
||||
<p class="text-danger" v-show="formError">{{ formError }}</p>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="none">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<FormGroup>
|
||||
<label for="passwordInput">{{ $t('users.setGhostDialog.password') }}</label>
|
||||
|
||||
@@ -59,7 +59,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('users.invitationDialog.title', { username: user? (user.username || user.email) : '' })"
|
||||
:title="$t('users.invitationDialog.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
reject-style="secondary"
|
||||
>
|
||||
@@ -68,6 +68,8 @@ defineExpose({
|
||||
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t('users.invitationDialog.context', { username: user? (user.username || user.email) : '' }) }}</p>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('users.invitationDialog.descriptionLink') }}</label>
|
||||
<InputGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import NetworkModel from '../models/NetworkModel.js';
|
||||
|
||||
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
|
||||
const providers = [
|
||||
{ name: 'Disabled', value: 'noop' },
|
||||
{ name: 'Public IP', value: 'generic' },
|
||||
{ name: 'Static IP Address', value: 'fixed' },
|
||||
{ name: 'Network Interface', value: 'network-interface' }
|
||||
{ name: 'Static IP address', value: 'fixed' },
|
||||
{ name: 'Network interface', value: 'network-interface' }
|
||||
];
|
||||
|
||||
function prettyIpProviderName(provider) {
|
||||
switch (provider) {
|
||||
case 'noop': return 'Disabled';
|
||||
case 'generic': return 'Public IP';
|
||||
case 'fixed': return 'Static IP Address';
|
||||
case 'network-interface': return 'Network Interface';
|
||||
case 'fixed': return 'Static IP address';
|
||||
case 'network-interface': return 'Network interface';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,16 @@ const editProvider = ref('');
|
||||
const editAddress = ref('');
|
||||
const editInterfaceName = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) return false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await networkModel.getIpv4Config();
|
||||
@@ -65,10 +69,11 @@ function onConfigure() {
|
||||
editInterfaceName.value = interfaceName.value || '';
|
||||
|
||||
dialog.value.open();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
editBusy.value = true;
|
||||
editError.value = {};
|
||||
@@ -100,39 +105,39 @@ onMounted(async () => {
|
||||
:title="$t('network.configureIp.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
|
||||
<input style="display: none" type="submit"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
|
||||
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
|
||||
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<p v-show="editProvider === 'generic'">
|
||||
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
|
||||
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fixed -->
|
||||
<FormGroup v-show="editProvider === 'fixed'">
|
||||
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
|
||||
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
|
||||
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
|
||||
<div class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Network Interface -->
|
||||
<FormGroup v-show="editProvider === 'network-interface'">
|
||||
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
|
||||
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
|
||||
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet -br addr <ClipboardAction plain value="ip -f inet -br addr" /></div>
|
||||
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
|
||||
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
|
||||
<div class="has-error" v-show="editError.ifname">{{ editError.ifname }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -140,10 +145,6 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('network.ip.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('network.ip.description') }}</div>
|
||||
<br/>
|
||||
|
||||
@@ -159,6 +160,8 @@ onMounted(async () => {
|
||||
<div class="info-label">{{ $t('network.ip.interface') }}</div>
|
||||
<div class="info-value">{{ interfaceName }}</div>
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import NetworkModel from '../models/NetworkModel.js';
|
||||
|
||||
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
|
||||
const providers = [
|
||||
{ name: 'Disabled', value: 'noop' },
|
||||
{ name: 'Public IP', value: 'generic' },
|
||||
{ name: 'Static IP Address', value: 'fixed' },
|
||||
{ name: 'Network Interface', value: 'network-interface' }
|
||||
{ name: 'Static IP address', value: 'fixed' },
|
||||
{ name: 'Network interface', value: 'network-interface' }
|
||||
];
|
||||
|
||||
function prettyIpProviderName(provider) {
|
||||
switch (provider) {
|
||||
case 'noop': return 'Disabled';
|
||||
case 'generic': return 'Public IP';
|
||||
case 'fixed': return 'Static IP Address';
|
||||
case 'network-interface': return 'Network Interface';
|
||||
case 'fixed': return 'Static IP address';
|
||||
case 'network-interface': return 'Network interface';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,16 @@ const editProvider = ref('');
|
||||
const editAddress = ref('');
|
||||
const editInterfaceName = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) return false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await networkModel.getIpv6Config();
|
||||
@@ -65,10 +69,11 @@ function onConfigure() {
|
||||
editInterfaceName.value = interfaceName.value || '';
|
||||
|
||||
dialog.value.open();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
editBusy.value = true;
|
||||
editError.value = {};
|
||||
@@ -100,23 +105,23 @@ onMounted(async () => {
|
||||
:title="$t('network.configureIpv6.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<div v-show="editProvider === 'generic'">
|
||||
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
|
||||
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +135,9 @@ onMounted(async () => {
|
||||
<!-- Network Interface -->
|
||||
<FormGroup v-show="editProvider === 'network-interface'">
|
||||
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
|
||||
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet6 -br addr <ClipboardAction plain value="ip -f inet6 -br addr" /></div>
|
||||
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
|
||||
<div class="error-label" v-show="editError.ifname">{{ editError.ifname }}</div>
|
||||
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet6 -br addr</code></p>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -140,10 +145,6 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('network.ipv6.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('network.ipv6.description') }}</div>
|
||||
<br/>
|
||||
|
||||
@@ -159,6 +160,8 @@ onMounted(async () => {
|
||||
<div class="info-label">{{ $t('network.ip.interface') }}</div>
|
||||
<div class="info-value">{{ interfaceName }}</div>
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, ClipboardButton, Checkbox, PasswordInput, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -19,17 +19,14 @@ const ldapUrl = ref('');
|
||||
const secret = ref('');
|
||||
const allowlist = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (enabled.value) {
|
||||
if (!secret.value) return false;
|
||||
if (!allowlist.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
editError.value = {};
|
||||
@@ -57,7 +54,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
ldapUrl.value = `ldaps://${result.adminFqdn}:636`;
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain) || domains[0];
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain);
|
||||
|
||||
[error, result] = await userDirectoryModel.getExposedLdapConfig();
|
||||
if (error) return console.error(error);
|
||||
@@ -65,6 +62,8 @@ onMounted(async () => {
|
||||
enabled.value = result.enabled;
|
||||
secret.value = result.secret;
|
||||
allowlist.value = result.allowlist;
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -72,11 +71,10 @@ onMounted(async () => {
|
||||
<template>
|
||||
<Section :title="$t('users.exposedLdap.title')">
|
||||
<div>{{ $t('users.exposedLdap.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit" :disabled="busy || !isValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<Checkbox v-model="enabled" :label="$t('users.exposedLdap.enabled')" help-url="https://docs.cloudron.io/user-directory/#ldap-directory-server"/>
|
||||
|
||||
@@ -92,14 +90,15 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="secretInput">{{ $t('users.exposedLdap.secret.label') }}</label>
|
||||
<div description v-html="$t('users.exposedLdap.secret.description', { userDN: 'cn=admin,ou=system,dc=cloudron' })"></div>
|
||||
<PasswordInput id="secretInput" v-model="secret" required />
|
||||
<PasswordInput id="secretInput" v-model="secret" required :disabled="!enabled" />
|
||||
<div class="has-error" v-show="editError.secret">{{ editError.secret }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="allowlistInput">{{ $t('users.exposedLdap.ipRestriction.label') }}</label>
|
||||
<div description v-html="$t('users.exposedLdap.ipRestriction.description')"></div>
|
||||
<textarea id="allowlistInput" v-model="allowlist" :placeholder="$t('users.exposedLdap.ipRestriction.placeholder')" rows="4" required></textarea>
|
||||
<textarea id="allowlistInput" v-model="allowlist" rows="4" required :disabled="!enabled"></textarea>
|
||||
<small style="helper-text">{{ $t('users.exposedLdap.ipRestriction.placeholder') }}</small>
|
||||
<div class="has-error" v-show="editError.allowlist">{{ editError.allowlist }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -108,6 +107,6 @@ onMounted(async () => {
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
|
||||
<br/>
|
||||
<Button :loading="busy" :disabled="!isValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
<Button :loading="busy" :disabled="!isFormValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
@@ -1,150 +1,144 @@
|
||||
<script>
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, onUnmounted, onMounted } from 'vue';
|
||||
import { Button, InputDialog, TopBar, MainLayout, ButtonGroup } from '@cloudron/pankow';
|
||||
import LogsModel from '../models/LogsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
|
||||
export default {
|
||||
name: 'LogsViewer',
|
||||
components: {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
InputDialog,
|
||||
MainLayout,
|
||||
TopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
logsModel: null,
|
||||
appsModel: null,
|
||||
busyRestart: false,
|
||||
showRestart: false,
|
||||
showFilemanager: false,
|
||||
showTerminal: false,
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
downloadUrl: '',
|
||||
logLines: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClear() {
|
||||
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
},
|
||||
onDownload() {
|
||||
this.logsModel.download();
|
||||
},
|
||||
async onRestartApp() {
|
||||
if (this.type !== 'app') return;
|
||||
const linesContainer = useTemplateRef('linesContainer');
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
const confirmed = await this.$refs.inputDialog.confirm({
|
||||
message: this.$t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: this.$t('main.dialog.yes'),
|
||||
rejectLabel: this.$t('main.dialog.no'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
let logsModel = null;
|
||||
const appsModel = AppsModel.create();
|
||||
let refreshInterval = 0;
|
||||
|
||||
if (!confirmed) return;
|
||||
const busyRestart = ref(false);
|
||||
const showRestart = ref(false);
|
||||
const showFilemanager = ref(false);
|
||||
const showTerminal = ref(false);
|
||||
const id = ref('');
|
||||
const name = ref('');
|
||||
const type = ref('');
|
||||
const downloadUrl = ref('');
|
||||
|
||||
this.busyRestart = true;
|
||||
function onClear() {
|
||||
while (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
}
|
||||
|
||||
const [error] = await this.appsModel.restart(this.id);
|
||||
if (error) return console.error(error);
|
||||
async function onRestartApp() {
|
||||
if (type.value !== 'app') return;
|
||||
|
||||
this.busyRestart = false;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const id = urlParams.get('id');
|
||||
if (!confirmed) return;
|
||||
|
||||
if (appId) {
|
||||
this.type = 'app';
|
||||
this.id = appId;
|
||||
this.name = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
this.type = 'task';
|
||||
this.id = taskId;
|
||||
this.name = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
this.type = 'crash';
|
||||
this.id = crashId;
|
||||
this.name = 'Crash ' + crashId;
|
||||
} else if (id) {
|
||||
if (id === 'box') {
|
||||
this.type = 'platform';
|
||||
this.id = id;
|
||||
this.name = 'Box';
|
||||
} else {
|
||||
this.type = 'service';
|
||||
this.id = id;
|
||||
this.name = 'Service ' + id;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
busyRestart.value = true;
|
||||
|
||||
this.logsModel = LogsModel.create(this.type, this.id);
|
||||
const [error] = await appsModel.restart(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
if (this.type === 'app') {
|
||||
this.appsModel = AppsModel.create();
|
||||
busyRestart.value = false;
|
||||
}
|
||||
|
||||
const [error, app] = await this.appsModel.get(this.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
this.showFilemanager = !!app.manifest.addons.localstorage;
|
||||
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${this.name}`;
|
||||
|
||||
this.downloadUrl = this.logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
this.$refs.linesContainer.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
this.logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (appId) {
|
||||
type.value = 'app';
|
||||
id.value = appId;
|
||||
name.value = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
type.value = 'crash';
|
||||
id.value = crashId;
|
||||
name.value = 'Crash ' + crashId;
|
||||
} else if (idParam) {
|
||||
if (idParam === 'box') {
|
||||
type.value = 'platform';
|
||||
id.value = idParam;
|
||||
name.value = 'Box';
|
||||
} else {
|
||||
type.value = 'service';
|
||||
id.value = idParam;
|
||||
name.value = 'Service ' + idParam;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value);
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
name.value = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
showFilemanager.value = !!app.manifest.addons.localstorage;
|
||||
showTerminal.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
showRestart.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${name.value}`;
|
||||
|
||||
downloadUrl.value = logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
refreshInterval = setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else if (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
linesContainer.value.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -84,9 +84,10 @@ onMounted(async () => {
|
||||
<div v-html="$t('email.dnsStatus.description', { emailDnsDocsLink:'https://docs.cloudron.io/email/#dns-records'})"></div>
|
||||
<br/>
|
||||
|
||||
<!-- DNS records including PTR4/PTR6 -->
|
||||
<div v-if="domainStatus.mx">
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<div>
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item">
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa-solid" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
@@ -95,6 +96,7 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ item.label }} record</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
@@ -131,8 +133,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="domainStatus.relay" class="record-item" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<div>
|
||||
<!-- outbound SMTP / Relay status -->
|
||||
<div v-if="domainStatus.relay" class="record-item">
|
||||
<div class="record-header" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus.relay.status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus.relay.status === 'failed',
|
||||
@@ -141,33 +144,41 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ $t('email.smtpStatus.outboundSmtp') }}</b>
|
||||
<i class="fa-solid" :class="domainStatus.relay.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen">
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen" @click.stop>
|
||||
{{ domainStatus.relay.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<!-- Blacklist -->
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item">
|
||||
<div v-if="domainStatus[key]">
|
||||
<div>
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
'fa-circle-minus text-success': domainStatus[key].status === 'skipped',
|
||||
'fa-circle-minus text-warning': domainStatus[key].status === 'skipped',
|
||||
}"></i>
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ key === 'rbl4' ? 'IPv4' : 'IPv6' }} {{ $t('email.smtpStatus.rblCheck') }}</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="item.isOpen">
|
||||
<div v-if="domainStatus[key].status !== 'failed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
<div v-if="domainStatus[key].status === 'passed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div v-else-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
{{ domainStatus[key] }}
|
||||
<div v-if="domainStatus[key].servers.length" v-html="$t('email.smtpStatus.blacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
<div v-else v-html="$t('email.smtpStatus.notBlacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
|
||||
<!-- servers is only the blocked servers -->
|
||||
<br/>
|
||||
<div v-for="server in domainStatus[key].servers" :key="server.name">
|
||||
<a :href="server.removal" target="_blank">{{ server.name }}</a>
|
||||
<a :href="server.removal" target="_blank">{{ server.name }} removal link</a>
|
||||
|
||||
<span v-if="server.txtRecords.length">TXT record: {{ server.txtRecords.join('. ') }}</span>
|
||||
<span v-else>No TXT Records</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,20 +189,19 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.record-item {
|
||||
.record-header {
|
||||
border-radius: var(--pankow-border-radius);
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
color: var(--pankow-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
.record-header:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.record-details {
|
||||
padding: 10px 30px;
|
||||
padding: 10px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import MailModel from '../models/MailModel.js';
|
||||
import { RELAY_PROVIDERS } from '../constants.js';
|
||||
import { prettyRelayProviderName } from '../utils';
|
||||
|
||||
const props = defineProps(['domain']);
|
||||
const props = defineProps({
|
||||
domain: { type: String, required: true },
|
||||
adminDomain: { type: String, required: true }
|
||||
});
|
||||
|
||||
const mailModel = MailModel.create();
|
||||
|
||||
@@ -20,7 +23,7 @@ const mailConfig = ref({});
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const adminDomain = ref('');
|
||||
const currentProvider = ref('cloudron-smtp');
|
||||
const provider = ref('cloudron-smtp');
|
||||
const host = ref('');
|
||||
const port = ref(1);
|
||||
@@ -51,7 +54,7 @@ function usesPasswordAuth(provider) {
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -94,6 +97,8 @@ async function onShowDialog() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -130,6 +135,8 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
currentProvider.value = provider.value;
|
||||
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
@@ -140,6 +147,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
provider.value = result.relay.provider;
|
||||
currentProvider.value = result.relay.provider;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -167,7 +175,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy" v-if="usesExternalServer(provider)">
|
||||
<input type="submit" style="display: none" :disabled="busy || !isFormValid"/>
|
||||
<input type="submit" style="display: none" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="hostInput">{{ $t('email.outbound.mailRelay.host') }}</label>
|
||||
@@ -207,7 +215,7 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.outbound.title') }} <sup><a href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>
|
||||
<b>{{ prettyRelayProviderName(provider) }}</b> - <span v-html="$t('email.outbound.description')"></span>
|
||||
<span>{{ prettyRelayProviderName(currentProvider) }}</span> / <span v-html="$t('email.outbound.description')"></span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center;">
|
||||
|
||||
@@ -109,20 +109,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<SettingsItem wrap>
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<div v-html="$t('emails.changeDomainDialog.description')"></div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
<form @submit.prevent="onSubmit()">
|
||||
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
|
||||
<form @submit.prevent="onSubmit()" style="display: flex; align-items: center; width: 100%; justify-content: end;" autocomplete="off">
|
||||
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
|
||||
|
||||
<InputGroup>
|
||||
<TextInput v-model="subdomain" :disabled="busy"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
|
||||
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('main.dialog.save') }}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
<InputGroup style="overflow: hidden;">
|
||||
<TextInput v-model="subdomain" :disabled="busy" style="width: 120px"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
|
||||
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('emails.changeDomainDialog.setAction') }}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef, computed, inject } from 'vue';
|
||||
import { Dialog, Button, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import MailboxesModel from '../models/MailboxesModel.js';
|
||||
@@ -10,6 +10,7 @@ const props = defineProps([ 'apps', 'users', 'groups', 'domains' ]);
|
||||
|
||||
const mailboxesModel = MailboxesModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
@@ -34,7 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
name: '',
|
||||
domain: '@' + props.domains[0].domain,
|
||||
domain: domain.value,
|
||||
label: '@' + domain.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,7 +44,15 @@ async function onRemoveAlias(index) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -78,7 +88,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
emit('success');
|
||||
emit('success', { domain: domain.value, name: name.value, fullName: name.value + '@' + domain.value });
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -91,25 +101,29 @@ defineExpose({
|
||||
mailbox.value = m;
|
||||
|
||||
name.value = m ? m.name : '';
|
||||
domain.value = m ? m.domain : props.domains[0].domain;
|
||||
domain.value = m ? m.domain : dashboardDomain.value;
|
||||
ownerId.value = m ? m.ownerId : '';
|
||||
aliases.value = m ? m.aliases : [];
|
||||
active.value = m ? m.active : true;
|
||||
enablePop3.value = m ? m.enablePop3 : false;
|
||||
storageQuotaEnabled.value = m && m.storageQuota ? true : false;
|
||||
storageQuota.value = m ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [{ separator: true, label: 'Users' }]
|
||||
.concat(props.users)
|
||||
.concat([{ separator: true, label: 'Groups' }])
|
||||
.concat(props.groups)
|
||||
.concat([{ separator: true, label: 'Apps' }])
|
||||
.concat(props.apps);
|
||||
storageQuota.value = m && m.storageQuota ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [];
|
||||
|
||||
// unify on .name for multiselect
|
||||
usersAndGroupsAndApps.value.forEach(u => {
|
||||
u.icon = u.name ? 'fa-solid fa-users' : (u.username ? 'fa-solid fa-user' : 'fa-solid fa-cube') ;
|
||||
u.name = u.name || u.username || u.label || u.fqdn;
|
||||
});
|
||||
if (props.users.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Users' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.users.map(u => {
|
||||
return { ...u, icon: 'fa-solid fa-user', name: u.username || u.displayName || u.email };
|
||||
}));
|
||||
|
||||
if (props.groups.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Groups' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.groups.map(g => {
|
||||
return { ...g, icon: 'fa-solid fa-users' };
|
||||
}));
|
||||
|
||||
if (props.apps.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Apps' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.apps.map(a => {
|
||||
return { ...a, icon: 'fa-solid fa-cube', name: a.label || a.fqdn };
|
||||
}));
|
||||
|
||||
domainList.value = props.domains.map(d => {
|
||||
return {
|
||||
@@ -120,6 +134,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -127,26 +143,25 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailbox ? $t('email.editMailboxDialog.title', { name: mailbox.name, domain: mailbox.domain }) : $t('email.addMailboxDialog.title')"
|
||||
:title="mailbox ? $t('email.editMailboxDialog.title') : $t('email.addMailboxDialog.title')"
|
||||
:confirm-label="$t(mailbox ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== ''"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" novalidate autocomplete="off">
|
||||
<form @submit.prevent="onSubmit()" novalidate autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
|
||||
|
||||
<FormGroup v-if="!mailbox">
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="!!mailbox" :required="!mailbox"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox" :required="!mailbox"/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="!domainHasIncomingEnabled">{{ $t('email.addMailboxDialog.incomingDisabledWarning') }}</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
@@ -154,7 +169,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.editMailboxDialog.owner') }}</label>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name"/>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="mailbox" v-model="active" :label="$t('email.updateMailboxDialog.activeCheckbox')"/>
|
||||
@@ -179,10 +194,9 @@ defineExpose({
|
||||
<Button tool danger icon="fa-solid fa-trash-alt" @click="onRemoveAlias(index)"/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
<div style="margin-top: 5px"></div>
|
||||
<div v-if="aliases.length === 0">
|
||||
{{ $t('email.editMailboxDialog.noAliases') }} <span class="actionable" @click="onAddAlias($event)">{{ $t('email.editMailboxDialog.addAliasAction') }}</span>
|
||||
{{ $t('email.editMailboxDialog.noAliases') }} <span class="actionable" @click="onAddAlias">{{ $t('email.editMailboxDialog.addAliasAction') }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="actionable" @click="onAddAlias($event)">{{ $t('email.editMailboxDialog.addAnotherAliasAction') }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { computed, ref, useTemplateRef, inject } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import MailinglistsModel from '../models/MailinglistsModel.js';
|
||||
|
||||
@@ -19,6 +19,11 @@ const membersText = ref('');
|
||||
const membersOnly = ref(false);
|
||||
const active = ref(true);
|
||||
const domainList = ref([]);
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
|
||||
const memberCount = computed(() => {
|
||||
return membersText.value.split('\n').map(m => m.trim()).filter(m => m).length;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
@@ -63,7 +68,7 @@ defineExpose({
|
||||
mailinglist.value = m;
|
||||
|
||||
name.value = m ? m.name : '';
|
||||
domain.value = m ? m.domain : props.domains[0].domain;
|
||||
domain.value = m ? m.domain : dashboardDomain.value;
|
||||
membersText.value = m ? m.members.join('\n') : '';
|
||||
membersOnly.value = m ? m.membersOnly : false;
|
||||
active.value = m ? m.active : true;
|
||||
@@ -83,7 +88,8 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailinglist ? $t('email.editMailinglistDialog.title', { name: mailinglist.name, domain: mailinglist.domain }) : $t('email.addMailinglistDialog.title')"
|
||||
:title="mailinglist ? $t('email.editMailinglistDialog.title') : $t('email.addMailinglistDialog.title')"
|
||||
:style="{ 'min-width': '700px' }"
|
||||
:confirm-label="$t(mailinglist ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== '' && membersText !== ''"
|
||||
@@ -99,17 +105,17 @@ defineExpose({
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
|
||||
|
||||
<FormGroup v-if="!mailinglist">
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="!!mailinglist"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailinglist"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }}</label>
|
||||
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }} ({{ memberCount }})</label>
|
||||
<textarea id="membersInput" v-model="membersText" rows="5"></textarea>
|
||||
<div class="error-label" v-if="formError.members">{{ formError.members }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { FormGroup, MultiSelect } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
|
||||
defineProps(['hasFtp']);
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
defineProps(['hasFtp', 'users', 'groups']);
|
||||
|
||||
const accessRestriction = defineModel('acl');
|
||||
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -32,7 +16,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.accessControl.operators.title') }} <sup><a href="https://docs.cloudron.io/apps/#operators" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
|
||||
<div description>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
|
||||
</FormGroup>
|
||||
|
||||
<div style="margin-top: 10px; margin-left: 20px; display: flex; gap: 10px;">
|
||||
|
||||
+17
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -15,16 +15,18 @@ const newPassword = ref('');
|
||||
const newPasswordRepeat = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!newPassword.value) return false;
|
||||
if (newPasswordRepeat.value !== newPassword.value) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (newPasswordRepeat.value !== newPassword.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -58,6 +60,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,27 +79,27 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;">
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.newPassword">
|
||||
<label>{{ $t('profile.changePassword.newPassword') }}</label>
|
||||
<PasswordInput v-model="newPassword" />
|
||||
<PasswordInput v-model="newPassword" required/>
|
||||
<div class="error-label" v-if="formError.newPassword">{{ formError.newPassword }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="newPasswordRepeat.length !== 0 && newPassword !== newPasswordRepeat">
|
||||
<label>{{ $t('profile.changePassword.newPasswordRepeat') }}</label>
|
||||
<PasswordInput v-model="newPasswordRepeat" />
|
||||
<PasswordInput v-model="newPasswordRepeat" required />
|
||||
<div class="error-label" v-if="newPasswordRepeat.length && newPassword !== newPasswordRepeat">{{ $t('profile.changePassword.errorPasswordsDontMatch') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changePassword.currentPassword') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -14,7 +14,7 @@ const udpPorts = defineModel('udp');
|
||||
<div v-for="ports in [ tcpPorts, udpPorts ]" :key="ports">
|
||||
<FormGroup v-for="(port, key) in ports" :key="key" style="margin-top: 10px;">
|
||||
<Checkbox :label="port.title" v-model="port.enabled" />
|
||||
<small>{{ port.description + '. ' + (port.portCount >=1 ? (port.portCount + ' ports. ') : '') }}</small>
|
||||
<small>{{ port.description + (port.portCount > 1 ? ('. ' + port.portCount + ' ports. ') : '') }}</small>
|
||||
<small v-show="port.readOnly">{{ $t('appstore.installDialog.portReadOnly') }}</small>
|
||||
<small class="has-error" v-if="error.port === port.value">Port already taken {{ port }}</small>
|
||||
<NumberInput v-model="port.value" :disabled="!port.enabled" :min="1"/>
|
||||
@@ -24,3 +24,10 @@ const udpPorts = defineModel('udp');
|
||||
</FormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pankow-form-group small {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+18
-12
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import { isValidEmail } from '@cloudron/pankow/utils';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -15,15 +15,19 @@ const busy = ref (false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!isValidEmail(email.value)) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!isValidEmail(email.value)) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -56,6 +60,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,21 +79,21 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.email">
|
||||
<label>{{ $t('profile.changeEmail.email') }}</label>
|
||||
<EmailInput v-model="email" />
|
||||
<EmailInput v-model="email" required/>
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changeEmail.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -11,6 +11,10 @@ defineProps({
|
||||
type: String,
|
||||
default: `${API_ORIGIN}/api/v1/cloudron/avatar`,
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -20,11 +24,13 @@ defineProps({
|
||||
<div class="public-page-layout-root">
|
||||
<div class="public-page-layout-left pankow-no-mobile" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
|
||||
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
|
||||
<div class="cloudron-name">{{ cloudronName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="public-page-layout-right">
|
||||
<div class="public-page-layout-mobile-logo">
|
||||
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
|
||||
<div class="cloudron-name">{{ cloudronName }}</div>
|
||||
</div>
|
||||
<div class="public-page-layout-right-slot">
|
||||
<slot></slot>
|
||||
@@ -94,11 +100,19 @@ defineProps({
|
||||
}
|
||||
}
|
||||
|
||||
.public-page-layout-left img {
|
||||
margin-bottom: 20%;
|
||||
.public-page-layout-left .cloudron-avatar {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.public-page-layout-left .cloudron-name {
|
||||
font-family: var(--font-family--header);
|
||||
font-weight: 400;
|
||||
font-size: 1.75em;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
@@ -141,11 +155,18 @@ defineProps({
|
||||
justify-content: start;
|
||||
flex-basis: unset;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.public-page-layout-right-slot {
|
||||
max-width: unset;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cloudron-avatar {
|
||||
border-radius: 10px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog } from '@cloudron/pankow';
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const status = ref(0);
|
||||
const message = ref('');
|
||||
const stackTrace = ref('');
|
||||
|
||||
async function onError(error) {
|
||||
// this is handled by the fetcher global error hook
|
||||
if (error.status === 401 || error.status >= 502 || error instanceof TypeError) return;
|
||||
|
||||
console.error(error);
|
||||
|
||||
status.value = error.status || 0;
|
||||
message.value = error.body?.message || error.message || 'unkown';
|
||||
|
||||
let stack = '';
|
||||
if (error.stack) stack = error.stack;
|
||||
else stack = (new Error()).stack;
|
||||
|
||||
if (stack.indexOf('Error') === 0) { // chrome v8
|
||||
stackTrace.value = stack.split('\n').slice(2, 7).map(l => l.slice(' at '.length).split(' ')[0] + '()').join('\n');
|
||||
} else { // firefox and safari
|
||||
stackTrace.value = stack.split('\n').slice(1, 7).map(l => l.split('@')[0] + '()').join('\n');
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
|
||||
if (!window.cloudron) window.cloudron = {};
|
||||
window.cloudron.onError = onError;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog" title="Unhandled error"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div>
|
||||
<label v-if="status">Status:</label>
|
||||
<pre v-if="status">{{ status }}</pre>
|
||||
<label>Details:</label>
|
||||
<pre>{{ message }}</pre>
|
||||
<label>Trace:</label>
|
||||
<pre>
|
||||
{{ stackTrace }}
|
||||
...
|
||||
</pre>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
|
||||
import { inject } from 'vue';
|
||||
import { inject, useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const mobileFilterBar = useTemplateRef('mobileFilterBar');
|
||||
|
||||
const isMobile = ref(false);
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -16,6 +20,19 @@ function onTitleBadge() {
|
||||
subscriptionRequiredDialog.value.open();
|
||||
}
|
||||
|
||||
function checkForMobile() {
|
||||
isMobile.value = window.innerWidth <= 576;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForMobile();
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,8 +45,9 @@ function onTitleBadge() {
|
||||
</div>
|
||||
<div class="section-header-title-badge" v-if="titleBadge" @click="onTitleBadge()">{{ titleBadge }}</div>
|
||||
</div>
|
||||
<div><slot name="header-buttons"></slot></div>
|
||||
<div><Teleport :disabled="!isMobile" :to="mobileFilterBar"><slot name="filter-bar"></slot></Teleport><slot name="header-buttons"></slot></div>
|
||||
</h2>
|
||||
<div class="section-mobile-filter-bar" v-show="isMobile && $slots['filter-bar']" ref="mobileFilterBar"></div>
|
||||
<hr class="section-divider"/>
|
||||
<div class="section-body">
|
||||
<slot></slot>
|
||||
@@ -102,4 +120,11 @@ function onTitleBadge() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-mobile-filter-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -36,6 +36,15 @@ watch(password, () => {
|
||||
formError.value.password = null;
|
||||
});
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (password.value !== passwordRepeat.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
@@ -104,17 +113,15 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicPageLayout :footerHtml="footer">
|
||||
<PublicPageLayout :footer-html="footer" :cloudron-name="cloudronName">
|
||||
<div>
|
||||
<div v-if="mode === MODE.SETUP">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<div>{{ $t('setupAccount.description') }}</div>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<p style="margin-bottom: 8px">{{ $t('setupAccount.description') }}</p>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset>
|
||||
<!-- prevents autofill -->
|
||||
<input type="password" style="display: none;"/>
|
||||
@@ -147,29 +154,23 @@ onMounted(async () => {
|
||||
</form>
|
||||
|
||||
<br/>
|
||||
<Button :disabled="busy || password !== passwordRepeat" :loading="busy" @click="onSubmit()">{{ $t('setupAccount.setupAction') }}</Button>
|
||||
<Button :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ $t('setupAccount.setupAction') }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.NO_USERNAME">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.noUsername.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.noUsername.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.INVALID_TOKEN">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3 class="error-label">{{ $t('setupAccount.invalidToken.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.invalidToken.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.DONE">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.success.title') }}</h3>
|
||||
<Button :href="dashboardUrl">{{ $t('setupAccount.success.openDashboardAction') }}</Button>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ defineExpose({
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.accessControl.sftp.port') }}</div>
|
||||
<div class="info-value">222 <ClipboardAction plain :value="222" /></div>
|
||||
<div class="info-value">222 <ClipboardAction plain value="222" /></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { onSwipe } from '@cloudron/pankow/gestures.js';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
defineProps({
|
||||
cloudronAvatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
},
|
||||
items: {
|
||||
type: Array
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
const sideBar = useTemplateRef('sideBar');
|
||||
const isVisible = ref(false);
|
||||
const isCollapsed = ref(!!window.localStorage['sideBarCollapsed']);
|
||||
|
||||
function open() {
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isVisible.value = false;
|
||||
}
|
||||
|
||||
function onToggleCollapse() {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
if (isCollapsed.value) window.localStorage['sideBarCollapsed'] = 'true';
|
||||
else window.localStorage.removeItem('sideBarCollapsed');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onSwipe(sideBar.value, (direction) => {
|
||||
if (direction === 'left') close();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar" ref="sideBar" :class="{ 'sidebar-closed': !isVisible, 'sidebar-collapsed': isCollapsed }">
|
||||
<Transition name="pankow-scale">
|
||||
<div class="sidebar-close-action" v-if="isVisible" @click="close()"><i class="fa-solid fa-xmark"></i></div>
|
||||
<div class="sidebar-open-action" v-else @click="open()"><i class="fa-solid fa-bars"></i></div>
|
||||
</Transition>
|
||||
<div class="sidebar-inner">
|
||||
<a href="#/" class="sidebar-logo" @click="close()">
|
||||
<img :src="cloudronAvatarUrl" :alt="cloudronName + ' icon'" v-tooltip.right="isCollapsed && !isMobile ? cloudronName : null"/> {{ cloudronName }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<SideBarItem v-for="item in items" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
:collapsed="isCollapsed"
|
||||
@close="close"
|
||||
/>
|
||||
</div>
|
||||
<div style="flex-grow: 1"></div>
|
||||
<div class="sidebar-collapse-action pankow-no-mobile" @click="onToggleCollapse()" v-tooltip.right="isCollapsed && !isMobile ? $t('main.sidebar.collapseAction') : null"><i class="fa-solid" :class="{ 'fa-arrow-left': !isCollapsed, 'fa-arrow-right': isCollapsed }"></i> <span v-if="!isCollapsed">{{ $t('main.sidebar.collapseAction') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
min-width: unset !important;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action {
|
||||
display: block;
|
||||
color: gray;
|
||||
border-radius: 3px;
|
||||
padding: 5px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 24px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-color-dark);
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 32px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 5px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
transition: left 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar-closed {
|
||||
position: fixed;
|
||||
left: -600px; /* depends on media query */
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, watch, inject } from 'vue';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
},
|
||||
route: {
|
||||
type: String,
|
||||
},
|
||||
active: {
|
||||
type: [ Boolean, Function ],
|
||||
default: false,
|
||||
},
|
||||
separator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
visible: {
|
||||
type: [ Boolean, Function ],
|
||||
default: true,
|
||||
},
|
||||
childItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
});
|
||||
|
||||
const isMenuExpanded = ref(false);
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
watch(() => props.collapsed, () => {
|
||||
isMenuExpanded.value = false;
|
||||
isMenuOpen.value = false;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
const active = props.active;
|
||||
return typeof active === 'function' ? active() : active ?? true;
|
||||
});
|
||||
|
||||
const isVisible = computed(() => {
|
||||
const visible = props.visible;
|
||||
return typeof visible === 'function' ? visible() : visible ?? true;
|
||||
});
|
||||
|
||||
function close() {
|
||||
isMenuOpen.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
const subMenuElement = useTemplateRef('subMenuElement');
|
||||
const elem = useTemplateRef('elem');
|
||||
|
||||
function getViewport() {
|
||||
const win = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName('body')[0],
|
||||
w = win.innerWidth || e.clientWidth || g.clientWidth,
|
||||
h = win.innerHeight || e.clientHeight || g.clientHeight;
|
||||
|
||||
return {
|
||||
width: w,
|
||||
height: h
|
||||
};
|
||||
}
|
||||
|
||||
function getHiddenElementSize(element) {
|
||||
if (element) {
|
||||
const originalVisibility = element.style.visibility;
|
||||
const originalDisplay = element.style.display;
|
||||
|
||||
element.style.visibility = 'hidden';
|
||||
element.style.display = 'block';
|
||||
|
||||
element.clientHeight; // force reflow
|
||||
|
||||
const height = element.offsetHeight;
|
||||
const width = element.offsetWidth;
|
||||
|
||||
element.style.display = originalDisplay;
|
||||
element.style.visibility = originalVisibility;
|
||||
|
||||
return { height, width };
|
||||
}
|
||||
return { height: 0, width: 0 };
|
||||
}
|
||||
|
||||
const subMenuFlipped = ref(false);
|
||||
function toggleMenu() {
|
||||
if (props.collapsed && !isMobile.value) {
|
||||
const size = getHiddenElementSize(subMenuElement.value);
|
||||
const viewport = getViewport();
|
||||
|
||||
// rect of triggering element
|
||||
const top = elem.value.getBoundingClientRect().top;
|
||||
const bottom = elem.value.getBoundingClientRect().bottom;
|
||||
const right = elem.value.getBoundingClientRect().right;
|
||||
|
||||
// vertically flip or not
|
||||
subMenuFlipped.value = false;
|
||||
let menuTop = top;
|
||||
if (top + size.height - document.body.scrollTop > viewport.height) {
|
||||
if (top - document.body.scrollTop > viewport.height/2) {
|
||||
menuTop = bottom - size.height;
|
||||
subMenuFlipped.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
subMenuElement.value.style.left = right + 10 + 'px';
|
||||
subMenuElement.value.style.top = menuTop + 'px';
|
||||
|
||||
isMenuOpen.value = true;
|
||||
} else {
|
||||
isMenuExpanded.value = !isMenuExpanded.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onBackdrop(event) {
|
||||
isMenuOpen.value = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible">
|
||||
<hr v-if="separator"/>
|
||||
<a v-else-if="!childItems?.length" class="sidebar-item" :class="{ active: isActive }" :href="route" @click="close()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }}</span></a>
|
||||
<div v-else-if="childItems.length" ref="elem" class="sidebar-item" :class="{ 'sidebar-item-menu-open': isMenuOpen ? '#e9ecef' : null }" @click="toggleMenu()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: isMenuExpanded }" style="margin-left: 6px;"></i></span></div>
|
||||
|
||||
<teleport to="#app">
|
||||
<div class="pankow-menu-backdrop" @click="onBackdrop($event)" @contextmenu="onBackdrop($event)" v-show="isMenuOpen"></div>
|
||||
<div v-show="isMenuOpen" ref="subMenuElement" class="sidebar-item-menu">
|
||||
<div :class="{ 'sidebar-item-menu-anchor': !subMenuFlipped }">
|
||||
<span class="sidebar-item-header">{{ label }}</span>
|
||||
</div>
|
||||
<div v-for="(item, index) in childItems" :key="item" :class="{ 'sidebar-item-menu-anchor': subMenuFlipped && index === childItems.length-1 }">
|
||||
<hr v-if="item.separator"/>
|
||||
<a v-else class="sidebar-item" :href="item.route" @click="close()"><i :class="item.icon"></i> {{ item.label }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="isMenuExpanded">
|
||||
<SideBarItem v-for="item in childItems" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
@close="close()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar-item-menu {
|
||||
position: absolute;
|
||||
z-index: 3002; /* backdrop is at 3001 -> see pankow */
|
||||
background-color: var(--navbar-background);
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
border-bottom-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item-menu-open {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-menu-anchor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
height: 37px;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-header {
|
||||
background-color: #e9ecef;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: var(--pankow-text-color);
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.7;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item-header,
|
||||
.sidebar-item:hover,
|
||||
.sidebar-item-menu-open,
|
||||
.sidebar-item-menu-anchor::before {
|
||||
background-color: #282d38;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-label-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar-item-label-collapsed {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,34 +5,29 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardAction, Menu, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import { download } from '../utils.js';
|
||||
import BackupInfoDialog from './BackupInfoDialog.vue';
|
||||
|
||||
const backupsModel = BackupsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
const columns = {
|
||||
preserveSecs: {
|
||||
label: '',
|
||||
icon: 'fa-solid fa-archive',
|
||||
width: '40px',
|
||||
sort: true
|
||||
},
|
||||
packageVersion: {
|
||||
label: t('backups.listing.version'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
creationTime: {
|
||||
label: t('main.table.created'),
|
||||
sort(a, b) {
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
@@ -47,19 +42,19 @@ const columns = {
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
sort: false,
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
sort: true
|
||||
packageVersion: {
|
||||
label: t('backups.listing.version'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-circle-info',
|
||||
label: t('backups.archives.info'),
|
||||
action: onInfo.bind(null, backup),
|
||||
@@ -72,8 +67,6 @@ function onActionMenu(backup, event) {
|
||||
label: t('backups.listing.tooltipDownloadBackupConfig'),
|
||||
action: onDownloadConfig.bind(null, backup),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const busy = ref(true);
|
||||
@@ -164,21 +157,19 @@ async function refreshBackups() {
|
||||
|
||||
result.forEach(function (backup) {
|
||||
backup.site = sites.value.find(t => t.id === backup.siteId);
|
||||
|
||||
// filled when opening the info dialog - we only show apps for the moment
|
||||
backup.contents = backup.dependsOn.filter(c => c.indexOf('app_') === 0).map(c => {
|
||||
return {
|
||||
id: c,
|
||||
label: null,
|
||||
fqdn: null,
|
||||
stats: null
|
||||
};
|
||||
});
|
||||
backup.appCount = backup.dependsOn.filter(c => c.indexOf('app_') === 0).length;
|
||||
});
|
||||
|
||||
backups.value = result;
|
||||
}
|
||||
|
||||
async function refreshBackupSites() {
|
||||
const [error, result] = await backupSitesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
sites.value = result;
|
||||
}
|
||||
|
||||
async function onDownloadConfig(backup) {
|
||||
const [error, dashboardConfig] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
@@ -190,38 +181,9 @@ async function onDownloadConfig(backup) {
|
||||
download(filename, JSON.stringify(backupConfig, null, 4));
|
||||
}
|
||||
|
||||
// backups info dialog
|
||||
const infoDialog = useTemplateRef('infoDialog');
|
||||
const infoBackup = ref({ contents: [] });
|
||||
async function onInfo(backup) {
|
||||
infoBackup.value = backup;
|
||||
infoDialog.value.open();
|
||||
|
||||
// amend detailed app info
|
||||
const appsById = {};
|
||||
|
||||
const [appsError, apps] = await appsModel.list();
|
||||
if (appsError) console.error('Failed to get apps list:', appsError);
|
||||
|
||||
(apps || []).forEach(function (app) {
|
||||
appsById[app.id] = app;
|
||||
});
|
||||
|
||||
for (const content of infoBackup.value.contents) {
|
||||
const match = content.id.match(/app_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) continue;
|
||||
const [error, backup] = await backupsModel.get(content.id);
|
||||
if (error) console.error(error);
|
||||
content.stats = backup.stats;
|
||||
const app = appsById[match[1]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
} else {
|
||||
content.id = match[1];
|
||||
}
|
||||
}
|
||||
infoDialog.value.open(backup);
|
||||
}
|
||||
|
||||
// edit backups dialog
|
||||
@@ -245,25 +207,23 @@ async function onEditSubmit() {
|
||||
|
||||
const [error] = await backupsModel.update(editBackupId.value, editBackupLabel.value, editBackupPersist.value ? -1 : 0);
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
editBackupBusy.value = false;
|
||||
editBackupError.value = error.body?.message || JSON.stringify(error);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshBackups();
|
||||
editBackupBusy.value = false;
|
||||
editDialog.value.close();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await refreshBackupSites();
|
||||
await refreshBackups();
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await backupSitesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
sites.value = result;
|
||||
|
||||
await refreshBackupSites();
|
||||
await refreshBackups();
|
||||
|
||||
busy.value = false;
|
||||
@@ -277,47 +237,7 @@ defineExpose({ refresh });
|
||||
|
||||
<template>
|
||||
<Section :title="$t('backups.listing.title')">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
|
||||
<Dialog ref="infoDialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ infoBackup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
|
||||
<div class="info-value">{{ infoBackup.label }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
<div class="info-value">
|
||||
<div>
|
||||
{{ infoBackup.remotePath }}
|
||||
<ClipboardAction plain :value="infoBackup.remotePath"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(infoBackup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ infoBackup.packageVersion }}</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<p class="text-muted">{{ $t('backups.backupDetails.list', { appCount: infoBackup.contents.length }) }}:</p>
|
||||
<div v-for="content in infoBackup.contents" :key="content.id">
|
||||
<a v-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
|
||||
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
|
||||
<span> {{ prettyFileSize(content.stats.size) }} - {{ content.stats.fileCount }} file(s)</span>
|
||||
</div>
|
||||
</Dialog>
|
||||
<BackupInfoDialog ref="infoDialog" />
|
||||
|
||||
<Dialog ref="editDialog"
|
||||
:title="$t('backups.backupEdit.title')"
|
||||
@@ -328,7 +248,7 @@ defineExpose({ refresh });
|
||||
:confirm-busy="editBackupBusy"
|
||||
@confirm="onEditSubmit()"
|
||||
>
|
||||
<p class="has-error text-center" v-show="editBackupError">{{ editBackupError }}</p>
|
||||
<div class="has-error text-center" v-show="editBackupError">{{ editBackupError }}</div>
|
||||
|
||||
<form @submit.prevent="onEditSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
@@ -337,38 +257,42 @@ defineExpose({ refresh });
|
||||
<TextInput id="backupLabelInput" v-model="editBackupLabel" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="editBackupPersist" :label="$t('backups.backupEdit.preserved.description')" />
|
||||
<Checkbox v-model="editBackupPersist" :label="$t('backups.backupEdit.preserved.description')" help-url="https://docs.cloudron.io/backups#backup-labels"/>
|
||||
<!-- <sup><a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{ 'backups.backupEdit.preserved.tooltip' | tr: { appsLength: editBackup.backup.contents.length} }}"><i class="fa fa-question-circle"></i></a></sup> -->
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<div v-html="t('backups.listing.description', { restoreLink: 'https://docs.cloudron.io/backups/#restore-cloudron', migrateLink: 'https://docs.cloudron.io/backups/#move-cloudron-to-another-server' })"></div>
|
||||
|
||||
<br/>
|
||||
|
||||
<template #header-buttons>
|
||||
<Button tool secondary :menu="taskLogsMenu" :disabled="!taskLogsMenu.length">{{ $t('main.action.logs') }}</Button>
|
||||
</template>
|
||||
|
||||
<TableView :columns="columns" :model="backups" :busy="busy" :placeholder="$t('backups.listing.noBackups')">
|
||||
<template #preserveSecs="backup">
|
||||
<i class="fas fa-archive" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i>
|
||||
<template #creationTime="backup">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #creationTime="backup">{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b></template>
|
||||
|
||||
<template #content="backup">
|
||||
<span v-if="backup.contents.length">{{ $t('backups.listing.appCount', { appCount: backup.contents.length }) }}</span>
|
||||
<span v-if="backup.appCount">{{ $t('backups.listing.appCount', { appCount: backup.appCount }) }}</span>
|
||||
<span v-else>{{ $t('backups.listing.noApps') }}</span>
|
||||
</template>
|
||||
|
||||
<template #size="backup">
|
||||
<span v-if="backup.stats.aggregated">{{ prettyFileSize(backup.stats.aggregated.size) }} - {{ backup.stats.aggregated.fileCount }} file(s)</span>
|
||||
<span v-if="backup.stats?.aggregatedUpload">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
|
||||
<template #site="backup">{{ backup.site.name }}</template>
|
||||
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(backup, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(backup)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -11,6 +11,7 @@ import SystemModel from '../models/SystemModel.js';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import GraphItem from './GraphItem.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { getColor } from '../utils.js';
|
||||
|
||||
const systemModel = SystemModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
@@ -85,21 +86,9 @@ async function liveRefresh() {
|
||||
};
|
||||
}
|
||||
|
||||
function generateConsistentColors(n, saturation = 90, lightness = 90) {
|
||||
const baseHue = 204; // from #9ad0f5 → hsl(204,82%,78%)
|
||||
const colors = [];
|
||||
const step = 360 / n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const hue = Math.round((baseHue + step * i) % 360); // rotate hue, wrap at 360
|
||||
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
function createDatasets() {
|
||||
const colors = generateConsistentColors((selectedContainers.value.length+1)*2); // 1 for the 'system'
|
||||
const colorCount = (selectedContainers.value.length+1)*2; // 1 for the 'system'
|
||||
const colors = Array.from({ length: colorCount }).map((e, idx) => getColor(colorCount, idx));
|
||||
|
||||
const datasets = {
|
||||
cpu: [],
|
||||
@@ -203,7 +192,8 @@ onUnmounted(async () => {
|
||||
<template>
|
||||
<Section :title="$t('system.graphs.title')">
|
||||
<template #header-buttons>
|
||||
<MultiSelect @select="rebuild()" v-model="selectedContainers" :options="allContainers" option-label="label" :search-threshold="20" select-all-label="Select All"/>
|
||||
<!-- do not rebuild on @select because rebuild is not reentrant! -->
|
||||
<MultiSelect @close="rebuild()" v-model="selectedContainers" :options="allContainers" option-label="label" :search-threshold="20" select-all-label="Select All"/>
|
||||
<SingleSelect @select="rebuild()" v-model="period" :options="periods" option-label="label"/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ async function onReboot() {
|
||||
confirmLabel: t('main.rebootDialog.rebootAction'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox } from '@cloudron/pankow';
|
||||
import { Button, FormGroup, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox, InputDialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES, ISTATES } from '../constants.js';
|
||||
import Section from '../components/Section.vue';
|
||||
@@ -11,34 +15,17 @@ import AppsModel from '../models/AppsModel.js';
|
||||
import UpdaterModel from '../models/UpdaterModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
import { cronDays, cronHours, prettySchedule, parseSchedule } from '../utils.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const updaterModel = UpdaterModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
function prettyAutoUpdateSchedule(pattern) {
|
||||
if (!pattern) return '';
|
||||
const tmp = pattern.split(' ');
|
||||
|
||||
if (tmp.length === 1) return tmp[0];
|
||||
|
||||
const hours = tmp[2].split(',');
|
||||
const days = tmp[5].split(',');
|
||||
const prettyDay = (days.length === 7 || days[0] === '*') ? 'Every day' : days.map((day) => { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(', ');
|
||||
|
||||
try {
|
||||
const prettyHour = hours.map((hour) => { return cronHours[parseInt(hour, 10)]; }).sort((a,b) => a.value - b.value).map(h => h.name).join(', ');
|
||||
return prettyDay + ' at ' + prettyHour;
|
||||
} catch (error) {
|
||||
console.error('Unable to build pattern.', error);
|
||||
return 'Custom pattern';
|
||||
}
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const updateDialog = useTemplateRef('updateDialog');
|
||||
|
||||
const ready = ref(false);
|
||||
const taskLogsMenu = ref([]);
|
||||
const apps = ref([]);
|
||||
const version = ref('');
|
||||
@@ -77,9 +64,6 @@ async function refreshAutoupdatePattern() {
|
||||
const [error, result] = await updaterModel.getAutoupdatePattern();
|
||||
if (error) return console.error(error);
|
||||
|
||||
// just keep the UI sane by supporting previous default pattern
|
||||
if (result.pattern === '00 30 1,3,5,23 * * *') result.pattern = '00 15 1,3,5,23 * * *';
|
||||
|
||||
currentPattern.value = result.pattern;
|
||||
configurePattern.value = result.pattern;
|
||||
}
|
||||
@@ -103,17 +87,13 @@ async function refreshPendingUpdateInfo() {
|
||||
}
|
||||
|
||||
function onShowConfigure() {
|
||||
configureType.value = configurePattern.value === 'never' ? 'never' : 'pattern';
|
||||
const tmp = currentPattern.value.split(' ');
|
||||
const hours = tmp[2] ? tmp[2].split(',') : [];
|
||||
const days = tmp[5] ? tmp[5].split(',') : [];
|
||||
if (days[0] === '*') configureDays.value = cronDays;
|
||||
else configureDays.value = days.map(day => { return parseInt(day, 10); });
|
||||
|
||||
try {
|
||||
configureHours.value = hours.map(hour => { return parseInt(hour, 10); });
|
||||
} catch (error) {
|
||||
console.error('Error parsing hour', error);
|
||||
if (currentPattern.value === 'never') {
|
||||
configureType.value = 'never';
|
||||
} else {
|
||||
configureType.value = 'pattern';
|
||||
const result = parseSchedule(currentPattern.value);
|
||||
configureDays.value = result.days; // Array of cronDays.id
|
||||
configureHours.value = result.hours; // Array of cronHours.id
|
||||
}
|
||||
|
||||
configureDialog.value.open();
|
||||
@@ -180,6 +160,7 @@ async function refreshTasks() {
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result[0] || {};
|
||||
if (result.length && !result[0].active && !result[0].success) updateError.value.generic = result[0].error.message;
|
||||
|
||||
taskLogsMenu.value = result.map(t => {
|
||||
return {
|
||||
@@ -201,8 +182,17 @@ async function onSubmitUpdate() {
|
||||
|
||||
const [error] = await updaterModel.update(skipBackup.value);
|
||||
if (error) {
|
||||
updateError.value.generic = error.message || 'Internal error';
|
||||
updateBusy.value = false;
|
||||
|
||||
updateDialog.value.close();
|
||||
|
||||
inputDialog.value.info({
|
||||
title: t('notifications.settings.cloudronUpdateFailed'),
|
||||
message: error.body ? error.body.message : 'Internal error. Please try again.',
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
confirmStyle: 'secondary'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,48 +241,49 @@ onMounted(async () => {
|
||||
await refreshPendingUpdateInfo();
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshTasks();
|
||||
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
|
||||
<Dialog ref="updateDialog"
|
||||
:title="$t('settings.updateDialog.title') + ` v${pendingUpdate ? pendingUpdate.version : ''}`"
|
||||
:title="$t('settings.updateDialog.title')"
|
||||
:confirm-label="$t('settings.updateDialog.updateAction')"
|
||||
:confirm-active="canUpdate"
|
||||
:confirm-busy="updateBusy"
|
||||
:confirm-style="pendingUpdate && pendingUpdate.unstable ? 'danger' : 'primary'"
|
||||
:confirm-style="pendingUpdate?.unstable ? 'danger' : 'primary'"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!updateBusy"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitUpdate()"
|
||||
>
|
||||
<div v-if="pendingUpdate">
|
||||
<div v-if="canUpdate">
|
||||
<p class="text-danger" v-if="pendingUpdate.unstable">{{ $t('settings.updateDialog.unstableWarning') }}</p>
|
||||
<div v-if="pendingUpdate && canUpdate">
|
||||
<h3>{{ $t('settings.updateDialog.updateAvailable', { newVersion: `v${pendingUpdate.version}` }) }}</h3>
|
||||
<p v-if="pendingUpdate.unstable" class="error-label">{{ $t('settings.updateDialog.unstableWarning') }}</p>
|
||||
|
||||
<div>{{ $t('settings.updateDialog.changes') }}:</div>
|
||||
<div class="changelog-container">
|
||||
<ul class="changelogs">
|
||||
<li v-for="change in pendingUpdate.changelog" :key="change" v-html="marked.parse(change)"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
|
||||
|
||||
<p v-if="updateError.generic" class="error-label">{{ updateError.generic }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p>{{ $t('settings.updateDialog.blockingApps') }}</p>
|
||||
<ul>
|
||||
<li v-for="app in inProgressApps" :key="app.id">{{ app.fqdn }}</li>
|
||||
<div>{{ $t('settings.updateDialog.changes') }}:</div>
|
||||
<div class="changelog-container">
|
||||
<ul class="changelogs">
|
||||
<li v-for="change in pendingUpdate.changelog" :key="change" v-html="marked.parse(change)"></li>
|
||||
</ul>
|
||||
<span>{{ $t('settings.updateDialog.blockingAppsInfo') }}</span>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
|
||||
</div>
|
||||
<!-- !canUpdate -->
|
||||
<div v-else>
|
||||
<p>{{ $t('settings.updateDialog.blockingApps') }}</p>
|
||||
<ul>
|
||||
<li v-for="app in inProgressApps" :key="app.id">{{ app.fqdn }}</li>
|
||||
</ul>
|
||||
<span>{{ $t('settings.updateDialog.blockingAppsInfo') }}</span>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -304,18 +295,20 @@ onMounted(async () => {
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitConfigure()"
|
||||
>
|
||||
<p v-html="$t('settings.updateScheduleDialog.description')"></p>
|
||||
<FormGroup>
|
||||
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
|
||||
|
||||
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
|
||||
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
|
||||
|
||||
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
|
||||
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')" style="margin-top: 10px"/>
|
||||
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
|
||||
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')"/>
|
||||
|
||||
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin-top: 10px">
|
||||
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="value"/></div>
|
||||
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="value"/></div>
|
||||
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin: 10px 0px 0px 25px">
|
||||
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('settings.updates.title')">
|
||||
@@ -326,10 +319,10 @@ onMounted(async () => {
|
||||
<div v-html="$t('settings.updates.description')"></div>
|
||||
<br/>
|
||||
|
||||
<SettingsItem>
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(currentPattern) || '-' }}</span>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettySchedule(currentPattern) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.disabled') }}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
@@ -337,16 +330,24 @@ onMounted(async () => {
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('system.info.cloudronVersion') }}</label>
|
||||
<span>{{ version }} <span v-if="!pendingUpdate">({{ $t('settings.updates.onLatest') }})</span></span>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<ProgressBar :value="lastTask.percent" v-if="updateBusy && lastTask" :busy="true" />
|
||||
<p v-if="updateBusy && lastTask">{{ lastTask.message }}</p>
|
||||
|
||||
<div class="error-label" v-if="stopError.generic">{{ stopError.generic }}</div>
|
||||
<div class="error-label" v-if="updateCheckError.generic">{{ updateCheckError.generic }}</div>
|
||||
<div class="error-label" v-if="updateError.generic">{{ updateError.generic }}</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
|
||||
<div class="button-bar" v-if="ready">
|
||||
<Button :disabled="checkingBusy" :loading="checkingBusy" v-if="!updateBusy" @click="onCheck()">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
<Button :danger="(pendingUpdate && pendingUpdate.unstable) ? true : undefined" :success="(pendingUpdate && !pendingUpdate.unstable) ? true : undefined" v-show="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
|
||||
<Button :danger="pendingUpdate?.unstable" :success="!pendingUpdate?.unstable" v-if="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
@@ -366,12 +367,7 @@ onMounted(async () => {
|
||||
.changelog-container {
|
||||
overflow: auto;
|
||||
max-height: 20lh;
|
||||
margin-bottom: 10px;
|
||||
padding-right: 0.5rem; /* space so scrollbar doesn’t overlap text */
|
||||
}
|
||||
|
||||
.skip-backup {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -46,11 +46,13 @@ async function onDownload() {
|
||||
downloadFileDownloadUrl.value = '';
|
||||
|
||||
const downloadFileName = await inputDialog.value.prompt({
|
||||
message: t('terminal.downloadAction'),
|
||||
title: t('terminal.download.title'),
|
||||
message: t('terminal.download.description'),
|
||||
value: '',
|
||||
confirmStyle: 'success',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('terminal.download.download'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!downloadFileName) return;
|
||||
@@ -137,9 +139,9 @@ function onSchedulerMenu(event) {
|
||||
async function onRestartApp() {
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no'),
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, inject } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox, MultiSelect, SingleSelect } from '@cloudron/pankow';
|
||||
import { Dialog, TextInput, EmailInput, FormGroup, Checkbox, MultiSelect, SingleSelect } from '@cloudron/pankow';
|
||||
import { ROLES } from '../constants.js';
|
||||
import ImagePicker from '../components/ImagePicker.vue';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
@@ -32,7 +32,6 @@ const roles = ref([]);
|
||||
const profile = ref({});
|
||||
const busy = ref(false);
|
||||
const profileLocked = ref(false);
|
||||
const external2FA = ref(false);
|
||||
const formError = ref({});
|
||||
const displayName = ref('');
|
||||
const email = ref('');
|
||||
@@ -40,32 +39,23 @@ const fallbackEmail = ref('');
|
||||
const avatarUrl = ref('');
|
||||
const username = ref('');
|
||||
const role = ref('');
|
||||
const groups = ref([]);
|
||||
const localGroups = ref([]);
|
||||
const localGroupIds = ref([]);
|
||||
const allGroups = ref([]);
|
||||
const allLocalGroups = ref([]);
|
||||
const active = ref(true);
|
||||
const sendInvite = ref(false);
|
||||
const isSelf = ref(false);
|
||||
const reset2FABusy = ref(false);
|
||||
|
||||
async function onReset2FA() {
|
||||
if (!user.value) return;
|
||||
|
||||
reset2FABusy.value = true;
|
||||
|
||||
const [error] = await usersModel.disableTwoFactorAuthentication(user.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
user.value.twoFactorAuthenticationEnabled = false;
|
||||
reset2FABusy.value = false;
|
||||
}
|
||||
|
||||
let avatarFile = 'src';
|
||||
function onAvatarChanged(file) {
|
||||
avatarFile = file;
|
||||
}
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
@@ -141,7 +131,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const [groupError] = await usersModel.setLocalGroups(userId, localGroups.value);
|
||||
const [groupError] = await usersModel.setLocalGroups(userId, localGroupIds.value);
|
||||
if (groupError) {
|
||||
formError.value.generic = groupError.body ? groupError.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
@@ -198,8 +188,7 @@ defineExpose({
|
||||
result.forEach(g => g.label = g.name);
|
||||
allGroups.value = result;
|
||||
allLocalGroups.value = result.filter(g => !g.source);
|
||||
groups.value = u ? u.groupIds : [];
|
||||
localGroups.value = (u ? u.groupIds.filter(g => !g.source) : []);
|
||||
localGroupIds.value = u ? u.groupIds.filter(gid => allLocalGroups.value.find(g => g.id === gid)) : [];
|
||||
|
||||
[error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
@@ -217,10 +206,11 @@ defineExpose({
|
||||
[error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
profileLocked.value = result.profileLocked;
|
||||
external2FA.value = result.external2FA;
|
||||
|
||||
imagePicker.value.reset();
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,24 +218,16 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="user ? $t('users.editUserDialog.title', { username: (user.username || user.email) }) : $t('users.addUserDialog.title')"
|
||||
:title="user ? $t('users.editUserDialog.title') : $t('users.addUserDialog.title')"
|
||||
:confirm-label="user ? $t('main.dialog.save') : $t('users.addUserDialog.addUserAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
alternate-style="secondary"
|
||||
:alternate-label="(user && user.twoFactorAuthenticationEnabled && !(user.source && external2FA)) ? $t('users.passwordResetDialog.reset2FAAction') : null"
|
||||
:alternate-busy="reset2FABusy"
|
||||
@alternate="onReset2FA()"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p class="text-warning" v-if="user && user.source">{{ $t('users.editUserDialog.externalLdapWarning') }}</p>
|
||||
|
||||
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" />
|
||||
|
||||
@@ -255,47 +237,51 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="emailInput" v-model="email" :disabled="(user && user.source) ? true : null" required />
|
||||
<div class="text-danger" v-if="formError.email">{{ formError.email }}</div>
|
||||
<div class="text-warning" v-if="user && user.source">{{ $t('users.editUserDialog.externalLdapWarning') }}</div>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<!-- if profile edit is locked a username has to be set here . username is editable if none is set -->
|
||||
<FormGroup :has-error="formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="!user?.username && profileLocked" :readonly="user?.username ? true : false" />
|
||||
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
|
||||
<div class="error-label" v-if="formError.username">{{ formError.username }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- if profile edit is locked a user has to be set here . username is editable until one is set -->
|
||||
<FormGroup v-if="!user || !user.username" :has-error="formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" />
|
||||
<small class="helper-text">{{ profileLocked ? '' : $t('users.user.usernamePlaceholder') }}</small>
|
||||
<div class="text-danger" v-if="formError.username">{{ formError.username }}</div>
|
||||
<FormGroup>
|
||||
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<EmailInput id="emailInput" v-model="email" :readonly="user?.source ? true : false" :required="user?.source ? false : true" />
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup style="flex-grow: 1">
|
||||
<label for="displayNameInput">{{ $t('users.user.fullName') }}</label>
|
||||
<TextInput id="displayNameInput" v-model="displayName" :disabled="(user && user.source) ? true : null"/>
|
||||
<TextInput id="displayNameInput" v-model="displayName" :readonly="user?.source ? true : false"/>
|
||||
<small v-if="!user || !user.username" class="helper-text">{{ $t('users.user.displayNamePlaceholder') }}</small> <!-- don't show if user has already signed up -->
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="fallbackEmailInput">{{ $t('users.user.recoveryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="fallbackEmailInput" v-model="fallbackEmail" />
|
||||
<EmailInput id="fallbackEmailInput" v-model="fallbackEmail" />
|
||||
<small class="helper-text">{{ $t('users.user.fallbackEmailPlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="profile.isAtLeastAdmin" :has-error="formError.role">
|
||||
<label for="roleInput">{{ $t('users.user.role') }} <sup><a href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="roleInput" v-model="role" :options="roles" option-key="id" option-label="name" :disabled="isSelf"/>
|
||||
<div class="text-danger" v-if="formError.role">{{ formError.role }}</div>
|
||||
<div class="error-label" v-if="formError.role">{{ formError.role }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- local groups. they can have local and external users -->
|
||||
<FormGroup>
|
||||
<label for="groupsInput">{{ $t('users.user.groups') }}</label>
|
||||
<div v-if="allGroups.length === 0">{{ $t('users.user.noGroups') }}</div>
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroupIds" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
|
||||
<Checkbox v-if="!user" v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
|
||||
<!-- on add, this is hidden for now, until we figure why one would want to add an inactive user -->
|
||||
<Checkbox v-if="user" v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
|
||||
<Checkbox v-else v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -92,7 +92,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('domains.domainWellKnown.title', { domain })"
|
||||
:title="$t('domains.wellknown.title')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
@@ -100,7 +100,9 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p v-html="$t('domains.domainDialog.wellKnownDescription', { domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' })"></p>
|
||||
<div v-html="$t('domains.wellknown.context', { domain })"></div>
|
||||
<br/>
|
||||
<div v-html="$t('domains.wellknown.description', { domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' })"></div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
|
||||
@@ -6,39 +6,64 @@ import AccessControl from '../AccessControl.vue';
|
||||
import OperatorAccessControl from '../OperatorAccessControl.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import { ACL_OPTIONS } from '../../constants.js';
|
||||
import UsersModel from '../../models/UsersModel.js';
|
||||
import GroupsModel from '../../models/GroupsModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const busy = ref(false);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
const loading = ref(false);
|
||||
const submitBusy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const accessRestrictionOption = ref(ACL_OPTIONS.ANY);
|
||||
const accessRestrictionAcl = ref({ users: [], groups: [] });
|
||||
const operatorAcl = ref({ users: [], groups: [] });
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
submitBusy.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
let [error] = await appsModel.configure(props.app.id, 'access_restriction', { accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? false : accessRestrictionAcl.value) });
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
submitBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
[error] = await appsModel.configure(props.app.id, 'operators', { operators: (operatorAcl.value.users.length || operatorAcl.value.groups.length) ? operatorAcl.value : null});
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
submitBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
submitBusy.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
const userIds = new Set();
|
||||
for (const u of result) {
|
||||
u.username = u.username || u.email; // ensure username
|
||||
userIds.add(u.id);
|
||||
}
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
const groupIds = new Set();
|
||||
for (const g of result) groupIds.add(g.id);
|
||||
|
||||
if (props.app.accessRestriction === null) {
|
||||
accessRestrictionOption.value = ACL_OPTIONS.ANY;
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
@@ -47,26 +72,31 @@ onMounted(() => {
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
} else {
|
||||
accessRestrictionOption.value = ACL_OPTIONS.RESTRICTED;
|
||||
accessRestrictionAcl.value = props.app.accessRestriction;
|
||||
accessRestrictionAcl.value = JSON.parse(JSON.stringify(props.app.accessRestriction)); // make a copy
|
||||
accessRestrictionAcl.value.users = accessRestrictionAcl.value.users.filter(uid => userIds.has(uid)); // remove deleted users
|
||||
accessRestrictionAcl.value.groups = accessRestrictionAcl.value.groups.filter(gid => groupIds.has(gid)); // remove deleted groups
|
||||
}
|
||||
|
||||
operatorAcl.value = { users: [], groups: [] };
|
||||
if (props.app.operators) {
|
||||
operatorAcl.value.users = props.app.operators.users;
|
||||
operatorAcl.value.groups = props.app.operators.groups;
|
||||
operatorAcl.value = JSON.parse(JSON.stringify(props.app.operators)); // make a copy
|
||||
operatorAcl.value.users = operatorAcl.value.users.filter(uid => userIds.has(uid)); // remove deleted users
|
||||
operatorAcl.value.groups = operatorAcl.value.groups.filter(gid => groupIds.has(gid)); // remove deleted groups
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!loading">
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="app.manifest" :hide-optional-sso-option="!app.sso"/>
|
||||
<br/>
|
||||
<OperatorAccessControl v-model:acl="operatorAcl" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :sso="app.sso" :installation="false"/>
|
||||
<div style="padding-top: 10px"></div>
|
||||
<OperatorAccessControl v-model:acl="operatorAcl" :users="users" :groups="groups" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button @click="onSubmit()" :loading="submitBusy" :disabled="submitBusy">{{ $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Icon, Button, Switch, Checkbox, FormGroup, TextInput, TableView, Menu, Dialog, ProgressBar } from '@cloudron/pankow';
|
||||
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { API_ORIGIN, RSTATES } from '../../constants.js';
|
||||
import { download } from '../../utils.js';
|
||||
@@ -16,6 +16,8 @@ import AppsModel from '../../models/AppsModel.js';
|
||||
import BackupSitesModel from '../../models/BackupSitesModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
import { TASK_TYPES } from '../../constants.js';
|
||||
import BackupInfoDialog from '../BackupInfoDialog.vue';
|
||||
import ActionBar from '../../components/ActionBar.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
@@ -24,26 +26,25 @@ const tasksModel = TasksModel.create();
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
const columns = ref({
|
||||
preserveSecs: {
|
||||
label: '',
|
||||
icon: 'fa-solid fa-archive',
|
||||
width: '40px',
|
||||
sort: true
|
||||
},
|
||||
packageVersion: {
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
creationTime: {
|
||||
label: t('main.table.created'),
|
||||
sort(a, b) {
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
sort: true,
|
||||
sort(a, b) {
|
||||
return b.name <= a.name ? 1 : -1;
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('app.backups.backups.time'),
|
||||
packageVersion: {
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
},
|
||||
actions: {
|
||||
@@ -52,10 +53,8 @@ const columns = ref({
|
||||
}
|
||||
});
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-info',
|
||||
label: t('backups.archives.info'),
|
||||
action: onInfo.bind(null, backup),
|
||||
@@ -70,7 +69,7 @@ function onActionMenu(backup, event) {
|
||||
icon: 'fa-solid fa-download',
|
||||
label: t('app.backups.backups.downloadBackupTooltip'),
|
||||
visible: backup.site.format === 'tgz' && props.app.accessLevel === 'admin',
|
||||
action: getDownloadLink.bind(null, backup),
|
||||
href: getDownloadLink(backup),
|
||||
}, {
|
||||
icon: 'fa-solid fa-file-alt',
|
||||
label: t('app.backups.backups.downloadConfigTooltip'),
|
||||
@@ -89,6 +88,7 @@ function onActionMenu(backup, event) {
|
||||
label: t('app.backups.backups.restoreTooltip'),
|
||||
disabled: !!props.app.taskId || props.app.runState === 'stopped',
|
||||
action: onRestore.bind(null, backup),
|
||||
quickAction: true
|
||||
// }, {
|
||||
// separator: true,
|
||||
// }, {
|
||||
@@ -97,13 +97,10 @@ function onActionMenu(backup, event) {
|
||||
// visible: props.app.accessLevel === 'admin',
|
||||
// action: onCheckIntegrity.bind(null, backup),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const busy = ref(true);
|
||||
const errorMessage = ref('');
|
||||
const infoBackup = ref({});
|
||||
const editBusy = ref(false);
|
||||
const editError = ref('');
|
||||
const editBackup = ref({});
|
||||
@@ -189,8 +186,7 @@ async function onStopBackup() {
|
||||
}
|
||||
|
||||
function onInfo(backup) {
|
||||
infoBackup.value = backup;
|
||||
infoDialog.value.open();
|
||||
infoDialog.value.open(backup);
|
||||
}
|
||||
|
||||
function onEdit(backup) {
|
||||
@@ -298,33 +294,10 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<AppRestoreDialog ref="cloneDialog"/>
|
||||
<AppImportDialog ref="importDialog"/>
|
||||
|
||||
<Dialog ref="infoDialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ infoBackup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
<div class="info-value">{{ infoBackup.remotePath }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(infoBackup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ infoBackup.packageVersion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<BackupInfoDialog ref="infoDialog" />
|
||||
|
||||
<Dialog ref="editDialog"
|
||||
:title="$t('backups.backupEdit.title')"
|
||||
@@ -338,22 +311,22 @@ onMounted(async () => {
|
||||
<div>
|
||||
<form @submit.prevent="onEditSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBusy">
|
||||
<p class="has-error" v-show="editError">{{ editError }}</p>
|
||||
<div class="has-error" v-show="editError">{{ editError }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="labelInput">{{ $t('backups.backupEdit.label') }}</label>
|
||||
<TextInput v-model="editLabel" id="labelInput" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="editPersist" :label="$t('backups.backupEdit.preserved.description')"/>
|
||||
<Checkbox v-model="editPersist" :label="$t('backups.backupEdit.preserved.description')" help-url="https://docs.cloudron.io/backups#backup-labels"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="restoreDialog"
|
||||
:title="$t('app.restoreDialog.title', { app: app.fqdn })"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:title="$t('app.restoreDialog.title')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('app.restoreDialog.restoreAction')"
|
||||
:confirm-active="true"
|
||||
@@ -361,16 +334,15 @@ onMounted(async () => {
|
||||
@confirm="onRestoreSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p>{{ $t('app.restoreDialog.description', { creationTime: prettyLongDate(restoreBackup.creationTime) }) }}</p>
|
||||
<p class="text-danger">{{ $t('app.restoreDialog.warning') }}</p>
|
||||
<br/>
|
||||
<p>{{ $t('app.restoreDialog.description', { fqdn: app.fqdn, creationTime: prettyLongDate(restoreBackup.creationTime) }) }}</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.backups.auto.title') }}</label>
|
||||
<div v-html="$t('app.backups.auto.description', { backupLink: '/#/backups' })"></div>
|
||||
<div v-html="$t('app.backups.auto.description')"></div>
|
||||
</FormGroup>
|
||||
<Switch v-model="autoBackupsEnabled" @change="onChangeAutoBackups"/>
|
||||
</SettingsItem>
|
||||
@@ -415,21 +387,22 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :model="backups" :columns="columns" :busy="busy" :placeholder="$t('backups.listing.noBackups')" style="max-height: 400px;" >
|
||||
<template #preserveSecs="backup">
|
||||
<Icon icon="fa-solid fa-archive" v-show="backup.preserveSecs === -1" />
|
||||
</template>
|
||||
<template #creationTime="backup">
|
||||
{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b>
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #site="backup">
|
||||
{{ backup.site.name }}
|
||||
</template>
|
||||
<template #size="backup">
|
||||
<span v-if="backup.stats">{{ prettyFileSize(backup.stats.size) }} - {{ backup.stats.fileCount }} file(s)</span>
|
||||
<span v-if="backup.stats?.upload">{{ prettyFileSize(backup.stats.upload.size) }} - {{ backup.stats.upload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click="onActionMenu(backup, $event)" icon="fa-solid fa-ellipsis" />
|
||||
<ActionBar style="width: 100px" :actions="createActionMenu(backup)"/>
|
||||
</div>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -41,15 +41,17 @@ const crontabDefault = `# +------------------------ minute (0 - 59)
|
||||
|
||||
const busy = ref(false);
|
||||
const crontab = ref('');
|
||||
const submitError = ref({});
|
||||
|
||||
async function onSubmit() {
|
||||
if (crontab.value === crontabDefault && !props.app.crontab) return;
|
||||
if (crontab.value === props.app.crontab) return;
|
||||
|
||||
submitError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
const [error] = await appsModel.configure(props.app.id, 'crontab', { crontab: crontab.value });
|
||||
if (error) return console.error(error);
|
||||
if (error) submitError.value.generic = error.body?.message || JSON.stringify(error);
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -73,6 +75,7 @@ onMounted(() => {
|
||||
</label>
|
||||
<div description>{{ $t('app.cron.description') }}</div>
|
||||
<textarea id="crontabInput" style="width: 100%; white-space: pre-wrap; font-family: monospace;" v-model="crontab" rows="10"></textarea>
|
||||
<div class="error-label" v-show="submitError.generic">{{ submitError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -89,7 +89,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<div style="display: inline-block;">
|
||||
<label>{{ $t('app.display.icon') }}</label>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" fallback-src="/img/appicon_fallback.png" @changed="onIconChanged" size="512" display-height="96px"/>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" fallback-src="/img/appicon_fallback.png" @changed="onIconChanged" :size="512" display-height="96px"/>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
@@ -99,13 +99,14 @@ onMounted(() => {
|
||||
<FormGroup>
|
||||
<label for="labelInput">{{ $t('app.display.label') }}</label>
|
||||
<TextInput id="labelInput" v-model="label"/>
|
||||
<div class="text-error" v-if="labelError">{{ labelError }}</div>
|
||||
<div class="error-label" v-if="labelError">{{ labelError }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="tagsInput">{{ $t('app.display.tags') }}</label>
|
||||
<TagInput id="tagsInput" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')"/>
|
||||
<div class="text-error" v-if="tagsError">{{ tagsError }}</div>
|
||||
<TagInput id="tagsInput" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')"/>
|
||||
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
|
||||
<div class="error-label" v-if="tagsError">{{ tagsError }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -128,21 +128,21 @@ onMounted(async () => {
|
||||
<div>
|
||||
<div v-if="hasSendmail">
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.email.from.title') }} <sup><a href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label>{{ $t('app.email.configuration.title') }} <sup><a href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<Radiobutton v-if="sendmailOptional" v-model="enableMailbox" :value="1" :label="$t('app.email.from.enable')"/>
|
||||
|
||||
<div style="margin-bottom: 18px;" :style="{ 'padding-left': sendmailOptional ? '25px' : '0' }">
|
||||
<div v-html="$t('app.email.from.enableDescription', { domain: app.domain, domainConfigLink: ('/#/email/' + app.domain) })"></div>
|
||||
<br/>
|
||||
<div style="margin-bottom: 12px;" :style="{ 'padding-left': sendmailOptional ? '25px' : '0' }">
|
||||
<div v-html="$t('app.email.from.enableDescription', { domain: app.domain, domainConfigLink: ('/#/email-domain/' + app.domain) })"></div>
|
||||
|
||||
<form @submit.prevent="onSendmailSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="enableMailbox === 0 || sendmailBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">
|
||||
<fieldset :disabled="enableMailbox === 0 || sendmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">
|
||||
<input type="submit" style="display: none;" :disabled="!sendmailMailboxName"/>
|
||||
|
||||
<FormGroup>
|
||||
<div class="has-error" v-if="sendmailError">{{ sendmailError }}</div>
|
||||
|
||||
<label>{{ $t('app.email.from.title') }}</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<TextInput v-if="sendmailSupportsDisplayName" v-model="sendmailDisplayName" :placeholder="$t('app.email.from.displayName')"/>
|
||||
<InputGroup>
|
||||
@@ -159,8 +159,8 @@ onMounted(async () => {
|
||||
<div v-if="sendmailOptional" style="padding-left: 25px;">{{ $t('app.email.from.disableDescription') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSendmailSubmit()" :loading="sendmailBusy" :disabled="sendmailBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
<br v-if="sendmailOptional"/>
|
||||
<Button @click="onSendmailSubmit()" :loading="sendmailBusy" :disabled="sendmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
</div>
|
||||
|
||||
<hr style="margin-top: 20px" v-if="hasSendmail && hasRecvmail"/>
|
||||
@@ -184,7 +184,7 @@ onMounted(async () => {
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onRecvmailSubmit()" :disabled="recvmailBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId" :loading="recvmailBusy">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
<Button @click="onRecvmailSubmit()" :disabled="recvmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId" :loading="recvmailBusy">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,9 +53,9 @@ onMounted(async () => {
|
||||
<table class="eventlog-table pankow-no-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('eventlog.time') }}</th>
|
||||
<th>{{ $t('eventlog.source') }}</th>
|
||||
<th>{{ $t('eventlog.details') }}</th>
|
||||
<th style="width: 160px">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%">{{ $t('eventlog.source') }}</th>
|
||||
<th style="word-break: break-all; overflow-wrap: anywhere;">{{ $t('eventlog.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -66,7 +66,7 @@ onMounted(async () => {
|
||||
<td v-html="eventlog.details"></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="4" class="eventlog-details">
|
||||
<td colspan="3" class="eventlog-details">
|
||||
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</td>
|
||||
|
||||
@@ -81,7 +81,7 @@ onMounted(() => {
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.description') }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.version }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
|
||||
<div class="info-value" v-else>{{ app.manifest.dockerImage }}</div>
|
||||
</div>
|
||||
|
||||
@@ -95,8 +95,11 @@ onMounted(() => {
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.packageVersion') }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId"><a :href="`/#/appstore/${app.manifest.id}?version=${app.manifest.version}`">{{ app.manifest.id }}@{{ app.manifest.version }}</a></div>
|
||||
<div class="info-value" v-else>{{ app.manifest.version }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">
|
||||
<a :href="`/#/appstore/${app.manifest.id}?version=${app.manifest.version}`">{{ app.manifest.id }}@{{ app.manifest.version }}</a>
|
||||
<ClipboardAction plain :value="app.manifest.id + '@' + app.manifest.version"/>
|
||||
</div>
|
||||
<div class="info-value" v-else>{{ app.manifest.version }} <ClipboardAction plain :value="app.manifest.version"/></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, inject } from 'vue';
|
||||
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { isValidDomain } from '@cloudron/pankow/utils';
|
||||
import { ISTATES } from '../../constants.js';
|
||||
@@ -13,6 +13,7 @@ const props = defineProps([ 'app' ]);
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const domains = ref([]);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
@@ -38,7 +39,7 @@ function isNoopOrManual(domain) {
|
||||
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
domain: domains.value[0].domain,
|
||||
domain: domain.value,
|
||||
subdomain: ''
|
||||
});
|
||||
}
|
||||
@@ -49,7 +50,7 @@ function onRemoveAlias(index) {
|
||||
|
||||
function onAddRedirect() {
|
||||
redirects.value.push({
|
||||
domain: domains.value[0].domain,
|
||||
domain: domain.value,
|
||||
subdomain: ''
|
||||
});
|
||||
}
|
||||
@@ -63,8 +64,16 @@ const formValid = computed(() => {
|
||||
}];
|
||||
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
|
||||
for (const d of aliases.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of aliases.value) {
|
||||
let subdomain = d.subdomain;
|
||||
// see apps.js:validateLocations()
|
||||
if (d.subdomain.startsWith('*')) {
|
||||
if (subdomain === '*') continue;
|
||||
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
|
||||
}
|
||||
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
|
||||
}
|
||||
|
||||
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
|
||||
|
||||
@@ -189,7 +198,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" novalidate>
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.details.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.location.location') }}</label>
|
||||
@@ -217,10 +226,9 @@ onMounted(async () => {
|
||||
|
||||
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="errorObject" :domain-provider="domainProvider"/>
|
||||
|
||||
<div v-if="app.manifest.multiDomain" style="margin-top: 20px">
|
||||
<FormGroup v-if="app.manifest.multiDomain">
|
||||
<label>{{ $t('app.location.aliases') }} <sup><a href="https://docs.cloudron.io/apps/#aliases" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<div v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}</div>
|
||||
<div v-for="(item, index) in aliases" :key="item" style="margin-bottom: 10px">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
@@ -232,13 +240,14 @@ onMounted(async () => {
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</div>
|
||||
|
||||
<div class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}. </span>
|
||||
<span class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<div style="margin-top: 20px">
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.location.redirections') }} <sup><a href="https://docs.cloudron.io/apps/#redirections" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<div v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}</div>
|
||||
<div v-for="(item, index) in redirects" :key="item" style="margin-bottom: 10px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
@@ -250,18 +259,29 @@ onMounted(async () => {
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</div>
|
||||
|
||||
<div class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}. </span>
|
||||
<span class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div class="has-error" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
|
||||
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
|
||||
<br v-if="needsOverwriteDns"/>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.details.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pankow-form-group small {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,7 +65,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.repair.restart.title') }}</label>
|
||||
<div>{{ $t('app.repair.restart.description') }}</div>
|
||||
<br/>
|
||||
<Button @click="onRestart()" :disabled="busyRestart || app.taskId || !!app.error" :loading="busyRestart || app.installationState === 'pending_restart'">{{ $t('app.repair.recovery.restartAction') }}</Button>
|
||||
<Button @click="onRestart()" :disabled="!!(busyRestart || app.taskId || !!app.error)" :loading="busyRestart || app.installationState === 'pending_restart'">{{ $t('app.repair.recovery.restartAction') }}</Button>
|
||||
</div>
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
@@ -73,7 +73,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.repair.recovery.title') }}</label>
|
||||
<div v-html="$t('app.repair.recovery.description', { docsLink: 'https://docs.cloudron.io/apps/#recovery-mode' })"></div>
|
||||
<br/>
|
||||
<Button @click="onToggleDebugMode" :disabled="debugModeBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_DEBUG) || app.taskId">
|
||||
<Button @click="onToggleDebugMode" :disabled="!!(debugModeBusy || (app.error && app.error.installationState !== ISTATES.PENDING_DEBUG) || app.taskId)">
|
||||
<span v-if="app.debugMode">{{ $t('app.repair.recovery.disableAction') }}</span>
|
||||
<span v-else>{{ $t('app.repair.recovery.enableAction') }}</span>
|
||||
</Button>
|
||||
@@ -84,9 +84,9 @@ onMounted(() => {
|
||||
<div>
|
||||
<label>{{ $t('app.repair.taskError.title') }}</label>
|
||||
<div>{{ $t('app.repair.taskError.description') }}</div>
|
||||
<div v-if="app.error" style="margin-top: 10px;">An error occurred during the <b>{{ taskNameFromInstallationState(app.error.details.installationState) }}</b> operation: <span class="text-danger"><b>{{ app.error.reason + ': ' + app.error.message }}</b></span></div>
|
||||
<div v-if="app.error" style="margin-top: 10px;">An error occurred during the <b>{{ taskNameFromInstallationState(app.error.installationState) }}</b> operation: <span class="text-danger"><b>{{ app.error.reason + ': ' + app.error.message }}</b></span></div>
|
||||
<br/>
|
||||
<Button @click="onRepair()" :disabled="busyRepair || app.taskId || !app.error" :loading="busyRepair">{{ $t('app.repair.taskError.retryAction', { task: app.error ? taskNameFromInstallationState(app.error.details.installationState) : '' }) }}</Button>
|
||||
<Button @click="onRepair()" :disabled="!!(busyRepair || app.taskId || !app.error)" :loading="busyRepair">{{ $t('app.repair.taskError.retryAction', { task: app.error ? taskNameFromInstallationState(app.error.installationState) : '' }) }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,20 +115,20 @@ onMounted(async () => {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('app.resources.memory.title') }} <sup><a href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ prettyBinarySize(memoryLimit, 'Default (256 MiB)') }}</b></label>
|
||||
<p>{{ $t('app.resources.memory.description') }}</p>
|
||||
<div description>{{ $t('app.resources.memory.description') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" step="134217728" :min="memoryTicks[0]" :max="memoryTicks[memoryTicks.length-1]" list="memoryLimitTicks" />
|
||||
<datalist id="memoryLimitTicks">
|
||||
<option v-for="value of memoryTicks" :key="value" :value="value"></option>
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<Button @click="onSubmitMemoryLimit()" :loading="memoryLimitBusy" :disabled="memoryLimitBusy || (!app.error && memoryLimit === currentMemoryLimit) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.memory.resizeAction') }}</Button>
|
||||
<Button @click="onSubmitMemoryLimit()" :loading="memoryLimitBusy" :disabled="memoryLimitBusy || (!app.error && memoryLimit === currentMemoryLimit) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.memory.resizeAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="cpuQuotaInput">{{ $t('app.resources.cpu.title') }} <sup><a href="https://docs.cloudron.io/apps/#cpu-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ cpuQuota + ' %' }}</b></label>
|
||||
<p>{{ $t('app.resources.cpu.description') }}</p>
|
||||
<div description>{{ $t('app.resources.cpu.description') }}</div>
|
||||
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" />
|
||||
<datalist id="cpuQuotaTicks">
|
||||
<option value="25"></option>
|
||||
@@ -137,21 +137,22 @@ onMounted(async () => {
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<Button @click="onSubmitCpuQuota()" :loading="cpuQuotaBusy" :disabled="cpuQuotaBusy || (!app.error && cpuQuota === currentCpuQuota) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.cpu.setAction') }}</Button>
|
||||
<Button @click="onSubmitCpuQuota()" :loading="cpuQuotaBusy" :disabled="cpuQuotaBusy || (!app.error && cpuQuota === currentCpuQuota) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.cpu.setAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<form @submit.prevent="onSubmitDevices()" autocomplete="off">
|
||||
<fieldset :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">
|
||||
<fieldset :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">
|
||||
<input style="display: none;" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="devicesInput">{{ $t('app.resources.devices.label') }} <sup><a href="https://docs.cloudron.io/apps/#devices" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div description>{{ $t('app.resources.devices.description') }}</div>
|
||||
<TagInput id="devicesInput" v-model="devices" placeholder="/dev/ttyUSB0, /dev/hidraw0, ..."/>
|
||||
<div class="text-danger" v-if="devicesError">{{ devicesError }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
<br/>
|
||||
<Button @click="onSubmitDevices()" :loading="devicesBusy" :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button @click="onSubmitDevices()" :loading="devicesBusy" :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">{{ $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user