Compare commits
469 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15c099056f | |||
| 8492cc3fa6 | |||
|
|
a4ea80cf5e | ||
|
|
feacb58cd1 | ||
|
|
1de30c0c38 | ||
|
|
4c30054a2d | ||
|
|
0b9e06c28d | ||
|
|
37e4a99ba6 | ||
|
|
7078eb7482 | ||
|
|
c2ec97d641 | ||
|
|
2a2a5ffb66 | ||
|
|
b84ef57d58 | ||
|
|
14b066d3cd | ||
|
|
2b5e167b07 | ||
|
|
c9547cbdb8 | ||
|
|
89a76148b4 | ||
|
|
81fd472bb3 | ||
|
|
4ba9c63eb4 | ||
|
|
9e20c5a3e3 | ||
|
|
20e0774df2 | ||
|
|
603244aa6a | ||
|
|
1cc30934c7 | ||
|
|
053f26cd02 | ||
|
|
cc82a088a9 | ||
|
|
e30e384cec | ||
|
|
33691a6507 | ||
|
|
83917f98f5 | ||
|
|
1fe5a61e52 | ||
|
|
dab9bcb9db | ||
|
|
b2ca6206cc | ||
|
|
918c2f8587 | ||
|
|
8f851164d6 | ||
|
|
d215d1998f | ||
|
|
75e3256497 | ||
|
|
58f5a17a83 | ||
|
|
e7c3d797be | ||
|
|
34abd5b8f5 | ||
|
|
8b138d14bb | ||
|
|
e23abd69b5 | ||
|
|
9c16ad456d | ||
|
|
4b851afc6a | ||
|
|
f333148afa | ||
|
|
8d0160a3e7 | ||
|
|
4a02e988c1 | ||
|
|
134472cd4b | ||
|
|
b40a10da7b | ||
|
|
25f5b33d17 | ||
|
|
f57c39bba2 | ||
|
|
99b234eca8 | ||
|
|
9c3c8cc9d1 | ||
|
|
b08e3a5128 | ||
|
|
e48cdc85f7 | ||
|
|
a5da68a7f9 | ||
|
|
7d594ab0d3 | ||
|
|
9ed3d668ee | ||
|
|
0da0a5e027 | ||
|
|
28eb0b65f4 | ||
|
|
1d29572ecd | ||
|
|
07e8d242d1 | ||
|
|
1586a286d8 | ||
|
|
4859059eba | ||
|
|
f2949c1836 | ||
|
|
cd6acfb91d | ||
|
|
2d5dc9a6aa | ||
|
|
87e7da2aff | ||
|
|
461eb38d88 | ||
|
|
ba0bb62fa3 | ||
|
|
1ca62dd38e | ||
|
|
1b1328c601 | ||
|
|
9633036887 | ||
|
|
e3d76ea9f4 | ||
|
|
d7212e69b5 | ||
|
|
ead58bd6f6 | ||
|
|
fbe13b75df | ||
|
|
6085a8231f | ||
|
|
e15cd190b3 | ||
|
|
3d55423deb | ||
|
|
f62df52c1d | ||
|
|
7829f94ac4 | ||
|
|
e9d42b9cdd | ||
|
|
1f05a8d92a | ||
|
|
69ae2b2997 | ||
|
|
b86e47de02 | ||
|
|
ea7647f43c | ||
|
|
ae7df52780 | ||
|
|
bc5737b9b0 | ||
|
|
d0745d1914 | ||
|
|
2b4c926a70 | ||
|
|
d922c1c80f | ||
|
|
67500a7689 | ||
|
|
1c8aa7440c | ||
|
|
d128dbec4c | ||
|
|
676cb8810b | ||
|
|
189e3d5599 | ||
|
|
009d0b39f9 | ||
|
|
81a8aa7c3d | ||
|
|
6c6761d14b | ||
|
|
7d2e3df929 | ||
|
|
f334c696cb | ||
|
|
db974d72d5 | ||
|
|
c15e342bb8 | ||
|
|
dc1449c7b6 | ||
|
|
0b305caf58 | ||
|
|
8f1f3645b2 | ||
|
|
0079162efe | ||
|
|
7afec06d4c | ||
|
|
29f85a8fd2 | ||
|
|
6e0dc24eca | ||
|
|
cee1180aa7 | ||
|
|
6db2b55e63 | ||
|
|
a3c038781f | ||
|
|
59c9e5397e | ||
|
|
a4c253b9a9 | ||
|
|
f12b4faf34 | ||
|
|
ff49759f42 | ||
|
|
01d0c738bc | ||
|
|
d57554a48c | ||
|
|
b16b57f38b | ||
|
|
12177446a2 | ||
|
|
61b15db958 | ||
|
|
349e8f5139 | ||
|
|
f30482808b | ||
|
|
79cdecdff6 | ||
|
|
336dee53cd | ||
|
|
77022bbd7f | ||
|
|
df96df776d | ||
|
|
67bc803859 | ||
|
|
8ef56c6d91 | ||
|
|
d377d1e1cf | ||
|
|
4209e4d90d | ||
|
|
83c85d02ee | ||
|
|
866b72d029 | ||
|
|
4bc0f44789 | ||
|
|
99c55cb22f | ||
|
|
74c73c695f | ||
|
|
b972891337 | ||
|
|
57515d54db | ||
|
|
0ff8dcc8e9 | ||
|
|
38efa6a2ba | ||
|
|
6306625184 | ||
|
|
1803ab303f | ||
|
|
e72dd7c845 | ||
|
|
87288caeb9 | ||
|
|
79b519e462 | ||
|
|
5f8ea2aecc | ||
|
|
94bc52a0c3 | ||
|
|
fed51bdcd9 | ||
|
|
5fc9689645 | ||
|
|
7c6a783fc8 | ||
|
|
764d479d7f | ||
|
|
ec15f29e40 | ||
|
|
2c12bee79b | ||
|
|
1120866b75 | ||
|
|
b362c069e5 | ||
|
|
4b6b18c182 | ||
|
|
80efc8c60c | ||
|
|
99168157fc | ||
|
|
23c3263562 | ||
|
|
1179a78fe1 | ||
|
|
82677ddd85 | ||
|
|
31f29e9086 | ||
|
|
3b3e606573 | ||
|
|
18b713cec3 | ||
|
|
2a6d385cea | ||
|
|
4cc1926899 | ||
|
|
15b2c2b739 | ||
|
|
197bf56271 | ||
|
|
4110f4b8ce | ||
|
|
becbaca858 | ||
|
|
add50257f6 | ||
|
|
f061ee5f88 | ||
|
|
480b81b3dd | ||
|
|
61d4a795ae | ||
|
|
cd89883dbb | ||
|
|
d5a729a2ba | ||
|
|
b41533c278 | ||
|
|
04758587b4 | ||
|
|
b6b0969879 | ||
|
|
18ef97fae6 | ||
|
|
333f052f86 | ||
|
|
7dd40eccf3 | ||
|
|
db728840a0 | ||
|
|
8906436824 | ||
|
|
e8dedb04a5 | ||
|
|
d4b581c007 | ||
|
|
a900beb3fd | ||
|
|
19a0f77c53 | ||
|
|
6dbd97ba14 | ||
|
|
d2fbea8e39 | ||
|
|
86a49c6223 | ||
|
|
e97f9b47d7 | ||
|
|
ee3ed5f660 | ||
|
|
3446f3d1e0 | ||
|
|
69d03a7a42 | ||
|
|
bab95cbefa | ||
|
|
5ef23fa49a | ||
|
|
f4ff63485a | ||
|
|
c20fbe8635 | ||
|
|
662cf65ff2 | ||
|
|
7ded517b20 | ||
|
|
4be31b0dad | ||
|
|
dc439ba5be | ||
|
|
5ba8a05450 | ||
|
|
7ef19b318a | ||
|
|
2ac76ad852 | ||
|
|
f4598f81c9 | ||
|
|
c432dbb5bc | ||
|
|
d0f0bb799e | ||
|
|
a98dbfdf4f | ||
|
|
a71909acd3 | ||
|
|
ea5953a397 | ||
|
|
4ad9ccabe0 | ||
|
|
17640d44fa | ||
|
|
812d471573 | ||
|
|
fa981d5a83 | ||
|
|
202f2c6cb0 | ||
|
|
55359bfa24 | ||
|
|
95fcfce9cd | ||
|
|
3120a2c43f | ||
|
|
7ba3a59dea | ||
|
|
eb5f8fcfa1 | ||
|
|
5014227028 | ||
|
|
7a76de2e4c | ||
|
|
de5692c1af | ||
|
|
555e4f0e65 | ||
|
|
723c670100 | ||
|
|
2f951dc272 | ||
|
|
0daabdc21c | ||
|
|
38a187e9fc | ||
|
|
5a613231e0 | ||
|
|
28a35e7260 | ||
|
|
461a5a780d | ||
|
|
207260821b | ||
|
|
466527884f | ||
|
|
9d03eb2643 | ||
|
|
c801202642 | ||
|
|
95952fae75 | ||
|
|
f62629b513 | ||
|
|
f04087815c | ||
|
|
255b1c63d0 | ||
|
|
9b5b8ddc22 | ||
|
|
d0a66f1701 | ||
|
|
c176ac600b | ||
|
|
cf0ab16533 | ||
|
|
03d0e2157e | ||
|
|
cdd5137ebe | ||
|
|
0a924b2c29 | ||
|
|
43acecfc6e | ||
|
|
5e7e739589 | ||
|
|
0b968b6a98 | ||
|
|
f14dfb6c17 | ||
|
|
cb5ccd8166 | ||
|
|
bfbcbb686d | ||
|
|
744300744c | ||
|
|
9bac099339 | ||
|
|
135c9fb64d | ||
|
|
4ed6fbbd74 | ||
|
|
4d3e9dc49b | ||
|
|
319360f8d0 | ||
|
|
3ef990b0bf | ||
|
|
b8ae46b6df | ||
|
|
113aba0897 | ||
|
|
a51672f3ee | ||
|
|
f08b3eb006 | ||
|
|
66f65093fc | ||
|
|
d78944e03b | ||
|
|
2fe31b876f | ||
|
|
9949ea364a | ||
|
|
77b7f7bfad | ||
|
|
8d4b458a22 | ||
|
|
2df8e77733 | ||
|
|
c21011a17a | ||
|
|
a11a691788 | ||
|
|
81659d4bf2 | ||
|
|
aab20fd23e | ||
|
|
5fad4dd034 | ||
|
|
7bc19e8185 | ||
|
|
45d0928ff9 | ||
|
|
9b768273f4 | ||
|
|
ef24b17a70 | ||
|
|
dfbe5aaa16 | ||
|
|
f499c9ada9 | ||
|
|
c1a73aa62a | ||
|
|
601e787500 | ||
|
|
d24bfabdc1 | ||
|
|
2c559d63f5 | ||
|
|
b5a1554631 | ||
|
|
510e1c7296 | ||
|
|
c6d8af5dc3 | ||
|
|
adf884c2c4 | ||
|
|
c7b321315c | ||
|
|
9f2eefcbb3 | ||
|
|
fc2e39f41b | ||
|
|
eae86d15ef | ||
|
|
361d80da17 | ||
|
|
2597402496 | ||
|
|
c8bc6f9ffe | ||
|
|
b0ef9238ff | ||
|
|
b71e503a01 | ||
|
|
e9f96593c3 | ||
|
|
36aa641cb9 | ||
|
|
ddb46646fa | ||
|
|
96dc79cfe6 | ||
|
|
e0e9f14a5e | ||
|
|
b24e1142f8 | ||
|
|
0543b16de9 | ||
|
|
8d46c09f95 | ||
|
|
5724ca73b4 | ||
|
|
3e09bef613 | ||
|
|
627b1fe33f | ||
|
|
1aa270485c | ||
|
|
ae09c19b69 | ||
|
|
c5cf8eef1a | ||
|
|
e76d4b3474 | ||
|
|
88a44ee065 | ||
|
|
51e02da277 | ||
|
|
e9c3e42aa6 | ||
|
|
93a0063941 | ||
|
|
26a3cf79c5 | ||
|
|
26999afc22 | ||
|
|
81729e4b2a | ||
|
|
4bae5ee2fb | ||
|
|
a786e6c8f5 | ||
|
|
3803f36aa5 | ||
|
|
55bc26bd09 | ||
|
|
d84037a0dd | ||
|
|
1ce5fcafd9 | ||
|
|
281233f48b | ||
|
|
68d73e088d | ||
|
|
b433191b35 | ||
|
|
d75ad44315 | ||
|
|
c3d3c3a6e9 | ||
|
|
b9b8ccb8ae | ||
|
|
5a56a7c8af | ||
|
|
d4efb63f3d | ||
|
|
2ec349e919 | ||
|
|
772770273a | ||
|
|
fa5cbfc304 | ||
|
|
5276321ade | ||
|
|
6303602323 | ||
|
|
486fb0d10a | ||
|
|
74b8a08251 | ||
|
|
2a244bb8d4 | ||
|
|
84e73943f7 | ||
|
|
ace09ca5a7 | ||
|
|
a9ae34b149 | ||
|
|
cff778fe6a | ||
|
|
be69f9f8a3 | ||
|
|
5ca2078461 | ||
|
|
4461e7225f | ||
|
|
49d5d10d77 | ||
|
|
84374f03e9 | ||
|
|
f8a44014f7 | ||
|
|
6befb64691 | ||
|
|
1ff2c21c61 | ||
|
|
c79d4a24c4 | ||
|
|
3d7a5676d8 | ||
|
|
aa362477e8 | ||
|
|
13b524e8a5 | ||
|
|
d6eb6d3e3e | ||
|
|
91b8f1a457 | ||
|
|
c8cdcfc99f | ||
|
|
fe20e738cd | ||
|
|
e23856bf10 | ||
|
|
a7de7fb286 | ||
|
|
a931d2a91f | ||
|
|
c94c66b71e | ||
|
|
cb89c30591 | ||
|
|
1012c0f654 | ||
|
|
9b5fb9ae8f | ||
|
|
c4055271a8 | ||
|
|
cd1df37ed3 | ||
|
|
3d8d4fd921 | ||
|
|
17b0c3e48d | ||
|
|
f364257db9 | ||
|
|
6b0d9f8551 | ||
|
|
f0fb420a8d | ||
|
|
8aa2695263 | ||
|
|
b9af8ee6be | ||
|
|
7077289840 | ||
|
|
bdd35fb02a | ||
|
|
47660c5679 | ||
|
|
28573f9676 | ||
|
|
375b7f6dd7 | ||
|
|
99ec2d5ce7 | ||
|
|
f2afd654f8 | ||
|
|
d42919285b | ||
|
|
33a1f135e0 | ||
|
|
214b836d13 | ||
|
|
408a07e8b9 | ||
|
|
b247731062 | ||
|
|
dcaa484929 | ||
|
|
35886633e5 | ||
|
|
d04afc26e7 | ||
|
|
bb5f1b703e | ||
|
|
dfe2d27709 | ||
|
|
1dec4f0070 | ||
|
|
89b6513217 | ||
|
|
16a8caa8db | ||
|
|
1594d190eb | ||
|
|
3333f70a64 | ||
|
|
5bd803e6b4 | ||
|
|
b5f5b096d4 | ||
|
|
dce05140bf | ||
|
|
29d5ac94b2 | ||
|
|
2b80c6c1ad | ||
|
|
94a62b040b | ||
|
|
2bb9c50db9 | ||
|
|
6533ba4581 | ||
|
|
081909572e | ||
|
|
aa84cb0079 | ||
|
|
a66c3700b3 | ||
|
|
70476bd168 | ||
|
|
a7929e142f | ||
|
|
fd0d65b8ce | ||
|
|
ef2a94c2c8 | ||
|
|
b43daf2f08 | ||
|
|
280f628746 | ||
|
|
713774c03f | ||
|
|
0889c1531e | ||
|
|
dbd5810a08 | ||
|
|
c8722e9945 | ||
|
|
87780a2fc8 | ||
|
|
ab03256db0 | ||
|
|
e26640c80e | ||
|
|
e6806453e1 | ||
|
|
d0fb2583a5 | ||
|
|
c4f8f318af | ||
|
|
a6286bb67e | ||
|
|
90aea9708c | ||
|
|
cb076123b3 | ||
|
|
70a9a66ae9 | ||
|
|
8521a47cfa | ||
|
|
106cc5238e | ||
|
|
2040eb22a2 | ||
|
|
b6075a9765 | ||
|
|
daacbcb89d | ||
|
|
6d622bbd14 | ||
|
|
f355da4874 | ||
|
|
4b36de5200 | ||
|
|
88d37e99aa | ||
|
|
1608fc3fdc | ||
|
|
057fd18139 | ||
|
|
b6371a0bdf | ||
|
|
03fe72e0b1 | ||
|
|
3bf4bddc10 | ||
|
|
92dcf19511 | ||
|
|
b238443a9d | ||
|
|
021a39a964 | ||
|
|
72c494e9dc | ||
|
|
42cefd56eb | ||
|
|
944f163882 | ||
|
|
11a8a73723 | ||
|
|
e34cf8f6a6 | ||
|
|
7f8143f06f | ||
|
|
472e513a9f | ||
|
|
1cbacab3a2 | ||
|
|
49bbb8588d | ||
|
|
23e0fe5791 | ||
|
|
6877dfb772 | ||
|
|
f65b33f3fc | ||
|
|
3daddf2fe6 | ||
|
|
efccf2729b | ||
|
|
3a1cd8f67f | ||
|
|
53c90429d3 | ||
|
|
7b5384a7d5 | ||
|
|
2b362d8eaf | ||
|
|
ce0024a43c | ||
|
|
888696975d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ installer/src/certs/server.key
|
||||
# vim swap files
|
||||
*.swp
|
||||
|
||||
.cursor
|
||||
|
||||
|
||||
114
CHANGES
114
CHANGES
@@ -3110,3 +3110,117 @@
|
||||
* 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
|
||||
|
||||
[9.1.0]
|
||||
* acme: ARI support . https://www.rfc-editor.org/rfc/rfc9773.txt
|
||||
* Update nodejs to 24.13.0
|
||||
* Update docker to 29.1.5
|
||||
* Update mongodb to 8.0.17
|
||||
* Update redis to 8.4.0
|
||||
* Add notification view. settings have moved to this new view.
|
||||
* updater: skip backup site check when user skips backup
|
||||
* community packages
|
||||
* source builds
|
||||
* backups: add integrity check UI
|
||||
* Fix fonts on chrome
|
||||
* applinks: fix acl UI
|
||||
* services: rename sftp to filemanager, graphite to metrics
|
||||
* app passwords: add expiry
|
||||
* DO Spaces: add missing ATL1, BLR1, SYD1 regions
|
||||
* filemanager: the terminal button automatically cds into the cwd
|
||||
* filemanager: add a tree view
|
||||
* passkey support
|
||||
* security: remove cors
|
||||
* support card/cal dav well-known endpoints
|
||||
* add backupCommand, restoreCommand, persistentDirs
|
||||
* Update Haraka to 3.1.3
|
||||
|
||||
[9.1.1]
|
||||
* cli: use web based browser login flow
|
||||
|
||||
[9.1.2]
|
||||
* apps: avoid flickering with filters
|
||||
* apps: move to error state if a volume is unavailable
|
||||
* apps: enable storage view in all error states
|
||||
* postgres: update pgvector to 0.8.2
|
||||
* appstore: add ai category
|
||||
* appstore: better tag/cateogry mapping
|
||||
* i18n: add Czech translations
|
||||
* Support and prefer Dockerfile.cloudron in local builds
|
||||
* integrity: show status in the info dialog
|
||||
* backup: show integrity column for dependsOn backups
|
||||
* integrity: show log link
|
||||
* syncer: fix bug with a file and dir having same prefix
|
||||
|
||||
[9.1.3]
|
||||
* Remove 'Dashboard' from dashboard page title
|
||||
* integrity: skip check of backups with no integrity info
|
||||
* backupintegrity: add percent progress
|
||||
* apps: fix acl display
|
||||
|
||||
[9.1.4]
|
||||
* services: lazy start services / on demand services
|
||||
* restore: fix restore of trusted ips and blocklist
|
||||
* dashboard: wait for dashboard reload when version has changed
|
||||
* graphite: fix aggregation of block/network read/write
|
||||
* Workaround chrome quirks on file drop handling
|
||||
* notifications: add empty text, progress bar and inifinite scroll
|
||||
* rsync: throttle log messages during download
|
||||
* backup logs: make them much terse and concise
|
||||
* oidc: implement Device Authorization Grant
|
||||
* operator: fix viewing of backup progress and logs
|
||||
* notification: automatic app update failure notification
|
||||
* backup sites: identify conflicting site locations
|
||||
* update: add policy to update apps and platform separately
|
||||
* passkey: fix issue where passkeys were lost on restart
|
||||
* passkey: implement passwordless login
|
||||
* oidcserver: fix jwks_rsaonly response
|
||||
|
||||
[9.1.5]
|
||||
* services: lazy start services / on demand services
|
||||
* restore: fix restore of trusted ips and blocklist
|
||||
* dashboard: wait for dashboard reload when version has changed
|
||||
* graphite: fix aggregation of block/network read/write
|
||||
* Workaround chrome quirks on file drop handling
|
||||
* notifications: add empty text, progress bar and inifinite scroll
|
||||
* rsync: throttle log messages during download
|
||||
* backup logs: make them much terse and concise
|
||||
* oidc: implement Device Authorization Grant
|
||||
* operator: fix viewing of backup progress and logs
|
||||
* notification: automatic app update failure notification
|
||||
* backup sites: identify conflicting site locations
|
||||
* update: add policy to update apps and platform separately
|
||||
* passkey: fix issue where passkeys were lost on restart
|
||||
* passkey: implement passwordless login
|
||||
* oidcserver: fix jwks_rsaonly response
|
||||
|
||||
[9.1.6]
|
||||
* apps: fix wrong disabled state for devices config
|
||||
* notifications: send email when manual platform and app update required
|
||||
* source install: support dockerfileName and build options
|
||||
* source install: persist buildConfig so restore, import, clone work correctly
|
||||
* search for matches in app links labels for apps view filter
|
||||
* restore: prune portBindings whose tcpPorts/udpPorts no longer exist
|
||||
* location: fix duplication of port bindings on submit
|
||||
* Update translations
|
||||
* location: show what DNS is being overwritten in location UI
|
||||
* backup site: remove the local disk provider
|
||||
* mail: update haraka to 3.1.4, tika to 3.3.0
|
||||
* solr: dynamically allocate java heap based on container mem
|
||||
|
||||
[9.2.0]
|
||||
* apppasswords: generate easier to type passwords
|
||||
* logs: escape and unescape new lines
|
||||
* backups/volumes: rename 'mountpoint' to 'User-managed Mount Point'
|
||||
* mail: listen on the bridge IP
|
||||
|
||||
106
PLAN.md
Normal file
106
PLAN.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Plan: Adding PowerDNS as a DNS Provider to Cloudron
|
||||
|
||||
This document outlines the detailed steps required to add support for PowerDNS via the PowerDNS Authoritative Server REST API to Cloudron. It covers frontend UI updates, backend DNS provider implementation, unit testing, and live testing instructions.
|
||||
|
||||
## 1. Frontend Modifications
|
||||
|
||||
The Cloudron dashboard is a Vue application that needs to know about the new provider and how to prompt the user for credentials.
|
||||
|
||||
### `dashboard/src/models/DomainsModel.js`
|
||||
* **Add Provider:** Append `{ name: 'PowerDNS', value: 'powerdns' }` to the `providers` array.
|
||||
* **Define Required Properties:** In the `getProviderConfigProps(provider)` switch statement, add a case for PowerDNS to define the fields it needs:
|
||||
```javascript
|
||||
case 'powerdns':
|
||||
props = ['apiUrl', 'apiKey'];
|
||||
break;
|
||||
```
|
||||
|
||||
### `dashboard/src/components/DomainProviderForm.vue`
|
||||
* **Reset Logic:** In the `dnsConfig` reset block (around line 57), add `dnsConfig.value.apiUrl = '';` to ensure the form clears properly.
|
||||
* **UI Template:** Add the HTML form groups for PowerDNS within the template section:
|
||||
```html
|
||||
<!-- powerdns -->
|
||||
<FormGroup v-if="provider === 'powerdns'">
|
||||
<label for="powerdnsApiUrlInput">{{ $t('domains.domainDialog.powerdnsApiUrl') }}</label>
|
||||
<TextInput id="powerdnsApiUrlInput" type="url" v-model="dnsConfig.apiUrl" placeholder="https://ns1.example.com:8081" required />
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'powerdns'">
|
||||
<label for="powerdnsApiKeyInput">{{ $t('domains.domainDialog.powerdnsApiKey') }}</label>
|
||||
<MaskedInput id="powerdnsApiKeyInput" v-model="dnsConfig.apiKey" required />
|
||||
</FormGroup>
|
||||
```
|
||||
|
||||
### `dashboard/public/translation/en.json`
|
||||
* **Add Translations:** Add the English translation strings for the new fields under the `domains.domainDialog` object:
|
||||
```json
|
||||
"powerdnsApiUrl": "PowerDNS API URL (e.g., https://ns1.example.com:8081)",
|
||||
"powerdnsApiKey": "API Key",
|
||||
```
|
||||
|
||||
## 2. Backend Modifications
|
||||
|
||||
The backend needs a new driver file that implements the standard Cloudron DNS provider interface.
|
||||
|
||||
### `src/dns.js`
|
||||
* **Import the Driver:** Add `import dnsPowerdns from './dns/powerdns.js';` at the top with the other imports.
|
||||
* **Register the Provider:** Add `powerdns: dnsPowerdns` to the `DNS_PROVIDERS` mapping object.
|
||||
|
||||
### `src/dns/powerdns.js` (New File)
|
||||
Create a new file that implements the standard DNS interface (`src/dns/interface.js`).
|
||||
* **Required Methods:** Export `removePrivateFields`, `injectPrivateFields`, `upsert`, `get`, `del`, `wait`, and `verifyDomainConfig`.
|
||||
* **API Interactions:**
|
||||
* Construct the base URL: `const baseUrl = domainConfig.apiUrl.replace(/\/$/, '');`
|
||||
* Ensure queries and zone names have a trailing dot, as PowerDNS strictly requires absolute FQDNs (e.g., `example.com.`).
|
||||
* Set the `X-API-Key` header using `domainConfig.apiKey` for all requests.
|
||||
* **Operations:**
|
||||
* `get`: Use `GET ${baseUrl}/api/v1/servers/localhost/zones/${zoneName}.` and extract records from the `rrsets` array matching the exact `fqdn` and `type`.
|
||||
* `upsert`: Use `PATCH ${baseUrl}/api/v1/servers/localhost/zones/${zoneName}.`. Send an `rrsets` payload with `changetype: 'REPLACE'`. Ensure `TXT` values are quoted.
|
||||
* `del`: Use `PATCH` with `changetype: 'DELETE'`.
|
||||
* **verifyDomainConfig:** Perform a test `upsert` and `del` of an `A` record on the subdomain `cloudrontestdns` to ensure the API is reachable and the key has edit permissions.
|
||||
|
||||
## 3. Automated Testing (Local)
|
||||
|
||||
The new provider needs to be verified against the existing test suite using mock HTTP requests.
|
||||
|
||||
### `src/test/dns-providers-test.js`
|
||||
* Add a new `describe('powerdns', ...)` block.
|
||||
* Setup mock domain configuration with a dummy `apiUrl` and `apiKey`.
|
||||
* Use `nock` to intercept requests to the dummy `apiUrl`.
|
||||
* Write tests for:
|
||||
* `upsert` non-existing and existing records.
|
||||
* `get` records.
|
||||
* `del` records.
|
||||
* Ensure the nock endpoints expect the exact JSON structure and `rrsets` payload required by PowerDNS.
|
||||
|
||||
### Running the Tests
|
||||
Run the specific test suite for DNS providers locally:
|
||||
```bash
|
||||
./run-tests src/test/dns-providers-test.js
|
||||
```
|
||||
*(This uses the fast-path to run mocha directly, skipping the full Cloudron test environment setup).*
|
||||
|
||||
## 4. Live Testing (Remote Cloudron Server)
|
||||
|
||||
To test the integration end-to-end, deploy the changes to a temporary Cloudron instance using the provided hotfix script.
|
||||
|
||||
**Important Considerations for Hotfixing:**
|
||||
The `scripts/hotfix` tool builds a release tarball by executing `git archive HEAD`. **You must commit your changes locally before running the hotfix**, otherwise the script will fail or push the old codebase.
|
||||
|
||||
1. **Commit Changes:**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Implement PowerDNS provider"
|
||||
```
|
||||
|
||||
2. **Run Hotfix Script:**
|
||||
Execute the hotfix script from the repository root, pointing it to your test server.
|
||||
```bash
|
||||
./scripts/hotfix --cloudron <IP_OR_DOMAIN_OF_TEST_SERVER> --ssh-user root --ssh-key ~/.ssh/id_rsa --release 1.0.0-test
|
||||
```
|
||||
|
||||
3. **Verify Deployment:**
|
||||
* The script will install dependencies, compile the Vue dashboard, bundle the backend code, push it to the server, and restart the `box` service.
|
||||
* Log into the Cloudron dashboard via your browser.
|
||||
* Navigate to Domains -> Add Domain, select "PowerDNS".
|
||||
* Enter a test domain, a valid PowerDNS API URL, and an API Key.
|
||||
* Verify that Cloudron successfully configures the domain and creates the necessary initial DNS records.
|
||||
103
box.js
103
box.js
@@ -1,17 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
import constants from './src/constants.js';
|
||||
import fs from 'node:fs';
|
||||
import ldapServer from './src/ldapserver.js';
|
||||
import net from 'node:net';
|
||||
import authServer from './src/authserver.js';
|
||||
import oidcServer from './src/oidcserver.js';
|
||||
import paths from './src/paths.js';
|
||||
import proxyAuth from './src/proxyauth.js';
|
||||
import safe from '@cloudron/safetydance';
|
||||
import server from './src/server.js';
|
||||
import directoryServer from './src/directoryserver.js';
|
||||
import logger from './src/logger.js';
|
||||
|
||||
const constants = require('./src/constants.js'),
|
||||
fs = require('node:fs'),
|
||||
ldapServer = require('./src/ldapserver.js'),
|
||||
net = require('node:net'),
|
||||
oidcServer = require('./src/oidcserver.js'),
|
||||
paths = require('./src/paths.js'),
|
||||
proxyAuth = require('./src/proxyauth.js'),
|
||||
safe = require('safetydance'),
|
||||
server = require('./src/server.js'),
|
||||
directoryServer = require('./src/directoryserver.js');
|
||||
const { log } = logger('box');
|
||||
|
||||
let logFd;
|
||||
|
||||
@@ -36,8 +38,10 @@ async function setupNetworking() {
|
||||
function exitSync(status) {
|
||||
const ts = new Date().toISOString();
|
||||
if (status.message) fs.write(logFd, `${ts} ${status.message}\n`, function () {});
|
||||
const msg = status.error.stack.replace(/\n/g, `\n${ts} `); // prefix each line with ts
|
||||
if (status.error) fs.write(logFd, `${ts} ${msg}\n`, function () {});
|
||||
if (status.error) {
|
||||
const escapedStack = status.error.stack.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
|
||||
fs.write(logFd, `${ts} ${escapedStack}\n`, function () {});
|
||||
}
|
||||
fs.fsyncSync(logFd);
|
||||
fs.closeSync(logFd);
|
||||
process.exit(status.code);
|
||||
@@ -54,50 +58,45 @@ async function startServers() {
|
||||
if (conf.enabled) await directoryServer.start();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [error] = await safe(startServers());
|
||||
if (error) return exitSync({ error, code: 1, message: 'Error starting servers' });
|
||||
const [error] = await safe(startServers());
|
||||
if (error) exitSync({ error, code: 1, message: 'Error starting servers' });
|
||||
|
||||
// require this here so that logging handler is already setup
|
||||
const debug = require('debug')('box:box');
|
||||
process.on('SIGHUP', async function () {
|
||||
log('Received SIGHUP. Re-reading configs.');
|
||||
const conf = await directoryServer.getConfig();
|
||||
if (conf.enabled) await directoryServer.checkCertificate();
|
||||
});
|
||||
|
||||
process.on('SIGHUP', async function () {
|
||||
debug('Received SIGHUP. Re-reading configs.');
|
||||
const conf = await directoryServer.getConfig();
|
||||
if (conf.enabled) await directoryServer.checkCertificate();
|
||||
});
|
||||
process.on('SIGINT', async function () {
|
||||
log('Received SIGINT. Shutting down.');
|
||||
|
||||
process.on('SIGINT', async function () {
|
||||
debug('Received SIGINT. Shutting down.');
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
setTimeout(() => {
|
||||
log('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
debug('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
process.on('SIGTERM', async function () {
|
||||
log('Received SIGTERM. Shutting down.');
|
||||
|
||||
process.on('SIGTERM', async function () {
|
||||
debug('Received SIGTERM. Shutting down.');
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
setTimeout(() => {
|
||||
log('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
debug('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => exitSync({ error, code: 1, message: 'From uncaughtException handler.' }));
|
||||
}
|
||||
|
||||
main();
|
||||
process.on('uncaughtException', (uncaughtError) => exitSync({ error: uncaughtError, code: 1, message: 'From uncaughtException handler.' }));
|
||||
|
||||
@@ -2,19 +2,59 @@
|
||||
|
||||
<script>
|
||||
|
||||
const tmp = window.location.hash.slice(1).split('&');
|
||||
(async function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
|
||||
// FIXME: implicit flow (response_type=code token) results in access_token query param. this is not secure
|
||||
tmp.forEach(function (pair) {
|
||||
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
|
||||
});
|
||||
if (!code) {
|
||||
console.error('No authorization code in callback URL');
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectTo = '/';
|
||||
if (localStorage.getItem('redirectToHash')) {
|
||||
redirectTo += localStorage.getItem('redirectToHash');
|
||||
localStorage.removeItem('redirectToHash');
|
||||
}
|
||||
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
|
||||
const clientId = sessionStorage.getItem('pkce_client_id') || 'cid-webadmin';
|
||||
const apiOrigin = sessionStorage.getItem('pkce_api_origin') || '';
|
||||
|
||||
window.location.replace(redirectTo); // this removes us from history
|
||||
sessionStorage.removeItem('pkce_code_verifier');
|
||||
sessionStorage.removeItem('pkce_client_id');
|
||||
sessionStorage.removeItem('pkce_api_origin');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiOrigin + '/openid/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: clientId,
|
||||
redirect_uri: window.location.origin + '/authcallback.html',
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.access_token) {
|
||||
console.error('Token exchange failed', data);
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.token = data.access_token;
|
||||
} catch (e) {
|
||||
console.error('Token exchange error', e);
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectTo = '/';
|
||||
if (localStorage.getItem('redirectToHash')) {
|
||||
redirectTo += localStorage.getItem('redirectToHash');
|
||||
localStorage.removeItem('redirectToHash');
|
||||
}
|
||||
|
||||
window.location.replace(redirectTo);
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
set -eu
|
||||
|
||||
# Check if the API origin is set, if not prompt the user to enter it
|
||||
if [[ -z "${DASHBOARD_DEVELOPMENT_ORIGIN:-}" ]]; then
|
||||
read -p "Enter the API origin (e.g. http://localhost:3000): " DASHBOARD_DEVELOPMENT_ORIGIN
|
||||
fi
|
||||
|
||||
echo "=> Set API origin"
|
||||
export VITE_API_ORIGIN="${DASHBOARD_DEVELOPMENT_ORIGIN}"
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export default [
|
||||
"prefer-const": "error",
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-undef-components": "error",
|
||||
'vue/no-root-v-if': "error",
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><%= name %> Dashboard</title>
|
||||
<title><%= name %></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
16
dashboard/oidc_device_confirm.html
Normal file
16
dashboard/oidc_device_confirm.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Confirm</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name, clientName, userCode, form }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdeviceconfirm.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
dashboard/oidc_device_input.html
Normal file
16
dashboard/oidc_device_input.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Device Sign-in</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name, message, form }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdeviceinput.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
dashboard/oidc_device_success.html
Normal file
16
dashboard/oidc_device_success.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Device Success</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdevicesuccess.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,13 +4,7 @@
|
||||
<title><%= name %> OpenID Error</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
errorMessage: errorMessage,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
window.cloudron = <%- JSON.stringify({ iconUrl, name, errorMessage, footer, language }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
name: name,
|
||||
note: note,
|
||||
submitUrl: submitUrl,
|
||||
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
|
||||
passkeyLoginUrl: passkeyLoginUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
|
||||
4250
dashboard/package-lock.json
generated
4250
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,26 +7,27 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.6.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@cloudron/pankow": "^4.1.10",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@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.3",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"marked": "^17.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"marked": "^18.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.2.7",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.3"
|
||||
"moment-timezone": "^0.6.1",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-singlefile": "^2.3.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-i18n": "^11.3.2",
|
||||
"vue-router": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
1750
dashboard/public/translation/cs.json
Normal file
1750
dashboard/public/translation/cs.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -296,8 +296,6 @@
|
||||
"password": "Adgangskode til bekræftelse"
|
||||
},
|
||||
"changePasswordAction": "Ændre adgangskode",
|
||||
"disable2FAAction": "Deaktivere 2FA",
|
||||
"enable2FAAction": "Aktiver 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail sendt til {{ email }}"
|
||||
}
|
||||
@@ -555,11 +553,8 @@
|
||||
"updateScheduleDialog": {
|
||||
"selectOne": "Vælg mindst én dag og ét tidspunkt",
|
||||
"description": "Vælg de dage og timer, hvor Cloudron vil anvende automatiske platforms- og appopdateringer. Pas på, at denne tidsplan ikke overlapper med <a href=\"/#/backups\">backup-tidsplanen</a>.",
|
||||
"title": "Konfigurer tidsplan for automatisk opdatering",
|
||||
"disableCheckbox": "Deaktivere automatiske opdateringer",
|
||||
"enableCheckbox": "Aktivere automatiske opdateringer",
|
||||
"days": "Dage",
|
||||
"hours": "Timer"
|
||||
"enableCheckbox": "Aktivere automatiske opdateringer"
|
||||
},
|
||||
"updateDialog": {
|
||||
"unstableWarning": "Denne opdatering er en præudgave og betragtes ikke som stabil endnu. Opdatering sker på egen risiko.",
|
||||
@@ -1067,11 +1062,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"description": "Apps kan stoppes for at spare på serverressourcerne i stedet for at blive afinstalleret. Fremtidige app-backups vil ikke omfatte app-ændringer mellem nu og den seneste app-backup. Derfor anbefales det at udløse en sikkerhedskopi, før appen stoppes.",
|
||||
"startAction": "Start app",
|
||||
"stopAction": "Stop App"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Afinstaller",
|
||||
"description": "Dette vil afinstallere appen med det samme og fjerne alle dens data. Der vil ikke være adgang til webstedet.",
|
||||
|
||||
@@ -42,10 +42,12 @@
|
||||
"next": "Weiter",
|
||||
"configure": "Konfigurieren",
|
||||
"restart": "Neu starten",
|
||||
"reset": "Zurücksetzen"
|
||||
"reset": "Zurücksetzen",
|
||||
"loadMore": "Mehr laden"
|
||||
},
|
||||
"table": {
|
||||
"version": "Version"
|
||||
"version": "Version",
|
||||
"created": "Erstellt"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"rebootDialog": {
|
||||
@@ -66,6 +68,9 @@
|
||||
"loadingPlaceholder": "Laden",
|
||||
"platform": {
|
||||
"startupFailed": "Plattform-Start fehlgeschlagen"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Seitenleiste einklappen"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -129,7 +134,6 @@
|
||||
"updateAvailableAction": "Aktualisierung verfügbar",
|
||||
"description": "Plattform und App-Aktualisierungen werden automatisch, basierend auf dem Zeitplan in der <a href=\"/#/system-settings\">Systemzeitzone</a> ausgeführt.",
|
||||
"disabled": "Deaktiviert",
|
||||
"schedule": "Aktualisierungszeitplan",
|
||||
"onLatest": "neueste"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
@@ -149,13 +153,10 @@
|
||||
}
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"hours": "Stunden",
|
||||
"disableCheckbox": "Automatische Aktualisierung deaktivieren",
|
||||
"enableCheckbox": "Automatische Aktualisierung aktivieren",
|
||||
"selectOne": "Mindestens einen Tag und eine Uhrzeit wählen",
|
||||
"days": "Tage",
|
||||
"description": "Tage und Stunden auswählen, an denen Cloudron das System und die Anwendungen aktualisieren soll. Der Zeitplan soll sich nicht mit dem Zeitplan für Datensicherungen überschneiden.",
|
||||
"title": "Automatische Aktualisierung konfigurieren"
|
||||
"description": "Tage und Stunden auswählen, an denen Cloudron das System und die Anwendungen aktualisieren soll. Der Zeitplan soll sich nicht mit dem Zeitplan für Datensicherungen überschneiden."
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Dient dazu, Datensicherungen und Updates zu planen. UI-Zeitstempel folgen immer der Zeitzone des Browsers.",
|
||||
@@ -365,8 +366,6 @@
|
||||
"description": "App-Passwörter sind eine Sicherheitsmaßnahme zum Schutz des Cloudron-User-Kontos. Sobald eingerichtet, kann die Anmeldung (zusätzlich) mit dem Usernamen und dem hier angezeigtem Passwort erfolgen. Hinweis: sinnvoll bei nicht vertrauenswürdigen mobilen Anwendungen oder Desktop-Clients.",
|
||||
"title": "App-Passwörter"
|
||||
},
|
||||
"enable2FAAction": "2FA aktivieren",
|
||||
"disable2FAAction": "2FA deaktivieren",
|
||||
"changePasswordAction": "Passwort ändern",
|
||||
"createApiToken": {
|
||||
"copyNow": "API-Token kopieren. Hinweis: keine erneute Anzeige des API-Tokens.",
|
||||
@@ -401,7 +400,7 @@
|
||||
"description": "Persönlichen Zugriffstoken zur Authentifizierung gegenüber der <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a> verwenden.",
|
||||
"name": "Name",
|
||||
"title": "API-Tokens",
|
||||
"lastUsed": "Zuletzt Verwendet",
|
||||
"lastUsed": "Zuletzt verwendet",
|
||||
"neverUsed": "nie",
|
||||
"scope": "Bereich",
|
||||
"readonly": "Schreibgeschützt",
|
||||
@@ -630,7 +629,12 @@
|
||||
"settingsDialog": {
|
||||
"description": "Eine E-Mail wird für die ausgewählten Ereignisse an Ihre primäre E-Mail-Adresse gesendet."
|
||||
},
|
||||
"allCaughtUp": "Alles erledigt"
|
||||
"allCaughtUp": "Alles erledigt",
|
||||
"title": "Benachrichtigungen",
|
||||
"showAll": "Alle",
|
||||
"showUnread": "Ungelesen",
|
||||
"markUnread": "Als ungelesen markieren",
|
||||
"markRead": "Als gelesen markieren"
|
||||
},
|
||||
"system": {
|
||||
"diskUsage": {
|
||||
@@ -741,12 +745,12 @@
|
||||
"enable": "Automatische Datensicherung aktivieren"
|
||||
},
|
||||
"backupDetails": {
|
||||
"version": "Version",
|
||||
"date": "Datum",
|
||||
"id": "Id",
|
||||
"version": "Paketversion",
|
||||
"date": "Erstellt",
|
||||
"id": "Datensicherungs Id",
|
||||
"title": "Backup-Details",
|
||||
"size": "Größe",
|
||||
"duration": "Dauer"
|
||||
"duration": "Datensicherungsdauer"
|
||||
},
|
||||
"listing": {
|
||||
"backupNow": "Backup jetzt erstellen",
|
||||
@@ -758,7 +762,8 @@
|
||||
"contents": "Inhalt",
|
||||
"noBackups": "Keine Datensicherungen",
|
||||
"title": "Datensicherungen",
|
||||
"tooltipPreservedBackup": "Dieses Backup bleibt erhalten"
|
||||
"tooltipPreservedBackup": "Dieses Backup bleibt erhalten",
|
||||
"description": "System-Datensicherungen enthalten die Cloudron-Konfiguration und Metadaten der App-Installation. Sie können dazu verwendet werden, die gesamte Cloudron-Installation auf einen anderen Server zu <a href=\"{{restoreLink}}\" target=\"_blank\">wiederherzustellen</a> oder zu <a href=\"{{migrateLink}}\" target=\"_blank\">migrieren</a>."
|
||||
},
|
||||
"schedule": {
|
||||
"retentionPolicy": "Aufbewahrungsrichtlinie",
|
||||
@@ -1181,7 +1186,7 @@
|
||||
"title": "Ungültiger oder abgelaufener Einladungslink"
|
||||
},
|
||||
"success": {
|
||||
"title": "Ihr Konto ist bereit",
|
||||
"title": "Konto ist bereit",
|
||||
"openDashboardAction": "Dashboard öffnen"
|
||||
},
|
||||
"fullName": "Vollständiger Name",
|
||||
@@ -1193,8 +1198,9 @@
|
||||
"description": "Konto einrichten",
|
||||
"noUsername": {
|
||||
"title": "Das Konto kann nicht eingerichtet werden.",
|
||||
"description": "Ein Konto kann nicht ohne einen Benutzernamen eingerichtet werden."
|
||||
}
|
||||
"description": "Ein Konto kann nicht ohne einen Benutzernamen eingerichtet werden. Kontaktiere den Administrator."
|
||||
},
|
||||
"welcome": "Willkommen"
|
||||
},
|
||||
"app": {
|
||||
"accessControl": {
|
||||
@@ -1209,10 +1215,10 @@
|
||||
"visibleForSelected": "Nur für die folgenden User und Gruppen sichtbar",
|
||||
"descriptionSftp": "Steuert auch den SFTP-Zugriff.",
|
||||
"visibleForAllUsers": "Sichtbar für alle User auf dieser Cloudron-Instanz",
|
||||
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann."
|
||||
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann"
|
||||
},
|
||||
"operators": {
|
||||
"description": "Die Betreiber können diese Anwendung konfigurieren und pflegen.",
|
||||
"description": "Wer kann Anwendung konfigurieren und pflegen",
|
||||
"title": "Administratoren"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
@@ -1232,19 +1238,37 @@
|
||||
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Geräte"
|
||||
"label": "Geräte",
|
||||
"description": "Durch Kommas getrennte Liste der an die App angeschlossenen Geräte"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": {
|
||||
"saveAction": "Speichern",
|
||||
"description": "Das Setzen dieser Option überschreibt alle CSP-Header, die von der Anwendung selbst gesendet werden.",
|
||||
"title": "Content-Security-Policy"
|
||||
"description": "Überschreibe alle CSP-Header, die von der App definiert sind.",
|
||||
"title": "Content-Security-Policy",
|
||||
"insertCommonCsp": "Gängige CSP einfügen",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Einbetten zulassen",
|
||||
"sameOriginEmbedding": "Einbetten zulassen (nur Unterdomänen)",
|
||||
"allowCdnAssets": "CDN-Ressourcen zulassen",
|
||||
"reportOnly": "CSP-Verstöße melden",
|
||||
"strictBaseline": "Strikte Baseline"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "robots.txt"
|
||||
"title": "robots.txt",
|
||||
"description": "Standardmäßig können Bots diese App indexieren",
|
||||
"commonPattern": {
|
||||
"allowAll": "Alle zulassen (Standard)",
|
||||
"disallowAll": "Alle verweigern",
|
||||
"disallowCommonBots": "Gängige Bots blockieren",
|
||||
"disallowAdminPaths": "Admin-Pfade sperren",
|
||||
"disallowApiPaths": "API-Pfade sperren"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Gängige robots.txt einfügen"
|
||||
},
|
||||
"hstsPreload": "Aktivieren Sie den HSTS-Preload für diese Website und alle Subdomains"
|
||||
"hstsPreload": "HSTS-Preload aktivieren (einschließlich Unterdomänen)"
|
||||
},
|
||||
"email": {
|
||||
"from": {
|
||||
@@ -1252,17 +1276,20 @@
|
||||
"mailboxPlaceholder": "Postfachname",
|
||||
"saveAction": "Speichern",
|
||||
"disableDescription": "Die E-Mail Einstellungen werden nicht automatisch vorgenommen, dies muss in der App selbst gemacht werden.",
|
||||
"enable": "Verwende Cloudron um E-Mails zu versenden",
|
||||
"enableDescription": "Diese App verwendet die <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail Konfiguration</a> der Domäne {{ domain }}.",
|
||||
"enable": "Verwende Cloudron, um E-Mails zu versenden",
|
||||
"enableDescription": "Konfigurieren Sie die App so, dass E-Mail über die untenstehende Adresse gesendet wird und <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail</a> Einstellungen.",
|
||||
"disable": "E-Mail Konfiguration nicht automatisch vornehmen",
|
||||
"displayName": "Absendername"
|
||||
},
|
||||
"inbox": {
|
||||
"title": "Eingehende E-Mail",
|
||||
"enable": "Benutze Cloudron Mail um E-Mails zu empfangen",
|
||||
"enableDescription": "Die App ist so konfiguriert, dass sie E-Mails über die unten stehende Adresse empfängt. Wählen Sie diese Option, wenn die E-Mail für {{ domain }} auf diesem Server gehostet wird.",
|
||||
"enableDescription": "Konfigurieren Sie die App so, dass sie E-Mail über die untenstehende Adresse empfängt. Wählen Sie diese Option, wenn die E-Mail von {{ domain }} auf diesem Server gehostet wird.",
|
||||
"disableDescription": "Die Einstellungen für den Posteingang in der App sind nicht betroffen. Sie können sie innerhalb der App konfigurieren. Wählen Sie dies, wenn die E-Mail der Domain nicht auf Cloudron gehostet wird.",
|
||||
"disable": "Posteingang nicht konfigurieren"
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Ausgehende E-Mails"
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
@@ -1285,13 +1312,8 @@
|
||||
},
|
||||
"repairTabTitle": "Reparatur",
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "Starten",
|
||||
"stopAction": "Stoppen",
|
||||
"description": "Anwendungen können angehalten werden, um Server-Ressourcen zu schonen, anstatt sie zu deinstallieren. Zukünftige Anwendungs-Backups werden keine Änderungen von Anwendungen zwischen jetzt und dem letzten Anwendungs-Backup enthalten. Aus diesem Grund wird empfohlen, vor dem Stoppen der Anwendung ein Backup auszulösen."
|
||||
},
|
||||
"uninstall": {
|
||||
"description": "Dies wird die Anwendung deinstallieren und alle zugehörigen Daten löschen. Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
|
||||
"description": "Anwendung deinstallieren und alle zugehörigen Daten löschen. Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
|
||||
"title": "Deinstallieren",
|
||||
"uninstallAction": "Deinstallieren"
|
||||
}
|
||||
@@ -1310,11 +1332,11 @@
|
||||
"appId": "ID der Anwendung",
|
||||
"lastUpdated": "Letzte Aktualisierung",
|
||||
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
|
||||
"packageVersion": "Paket-Version",
|
||||
"packageVersion": "Paket",
|
||||
"installedAt": "Installationszeitpunkt"
|
||||
},
|
||||
"auto": {
|
||||
"description": "App-Updates werden regelmäßig gemäß dem Aktualisierungszeitplan angewendet.",
|
||||
"description": "App-Updates werden regelmäßig gemäß dem <a href=\"/#/system-update\">Aktualisierungszeitplan</a> angewendet",
|
||||
"title": "Automatische Updates"
|
||||
},
|
||||
"updates": {
|
||||
@@ -1325,7 +1347,7 @@
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"downloadConfigTooltip": "Konfiguration herunterladen",
|
||||
"description": "Backups erstellen komplette Abbilder der Anwendung. Ein Anwendungsbackup kann zum Wiederherstellen oder Klonen dieser Anwendung verwendet werden.",
|
||||
"description": "Vollständige Datensicherung der App erstellen",
|
||||
"importAction": "Backup importieren",
|
||||
"cloneTooltip": "Duplizieren",
|
||||
"restoreTooltip": "Wiederherstellen",
|
||||
@@ -1335,11 +1357,11 @@
|
||||
},
|
||||
"auto": {
|
||||
"title": "Automatische Backups",
|
||||
"description": "Die App wird periodisch gemäß dem Datensicherungszeitplan gesichert."
|
||||
"description": "Regelmäßig eine Datensicherung der App auf die konfigurierten <a href=\"/#/backup-sites\">Datensicherungsstandorte</a> erstellen"
|
||||
},
|
||||
"import": {
|
||||
"title": "Von einem externen Backup importieren",
|
||||
"description": "Dies hier verwenden, um eine Anwendung von einer anderen Cloudron-Instanz zu migrieren. Die zu migrierende Anwendung muss die gleiche Paket-Version und Zugriffsrechte aufweisen wie diese hier."
|
||||
"title": "Importieren",
|
||||
"description": "App aus einer externen Datensicherung importieren"
|
||||
}
|
||||
},
|
||||
"appInfo": {
|
||||
@@ -1352,14 +1374,14 @@
|
||||
"storage": {
|
||||
"appdata": {
|
||||
"title": "Datenverzeichnis",
|
||||
"description": "Wenn dem Server der Speicherplatz ausgeht, kann durch Hinzufügen einer <a href=\"/#/volumes\">externen Festplatte</a>, die Daten der Anwendung dorthin verschoben werden.",
|
||||
"description": "Verschiebe die App-Daten auf einen <a href=\"/#/volumes\">Datenträger</a>. Alle hier befindlichen Daten sind in der Datensicherung der App enthalten.",
|
||||
"moveAction": "Daten verschieben",
|
||||
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
|
||||
},
|
||||
"mounts": {
|
||||
"title": "Datenträger Mounts",
|
||||
"volume": "Datenträger",
|
||||
"noMounts": "Es sind keine Datenträger gemounted.",
|
||||
"noMounts": "Kein Datenträger eingehängt",
|
||||
"addMountAction": "Einen Datenträger mount hinzufügen",
|
||||
"saveAction": "Speichern",
|
||||
"permissions": {
|
||||
@@ -1370,15 +1392,15 @@
|
||||
}
|
||||
},
|
||||
"uninstallDialog": {
|
||||
"title": "{{ app }} deinstallieren",
|
||||
"description": "Dies wird {{ app }} sofort deinstallieren und alle Daten löschen.",
|
||||
"title": "Anwendung deinstallieren",
|
||||
"description": "Anwendung {{ app }} deinstallieren und alle Daten löschen.",
|
||||
"uninstallAction": "Deinstallieren"
|
||||
},
|
||||
"restoreDialog": {
|
||||
"warning": "Alle Daten, die zwischen jetzt und der letzten bekannten Sicherung erzeugt wurden, gehen unwiderruflich verloren. Es wird empfohlen, ein Backup der aktuellen Daten zu erstellen, bevor eine Wiederherstellung versucht wird.",
|
||||
"warning": "Alle Daten, die seit der letzten Datensicherung erstellt wurden, gehen dauerhaft verloren. Es wird empfohlen, vor dem Import eine neue Datensicherung zu erstellen.",
|
||||
"restoreAction": "Wiederherstellen",
|
||||
"title": "{{ app }} wiederherstellen",
|
||||
"description": "Hierdurch wird diese Anwendung mit den Daten vom {{ creationTime }} wiederhergestellt.",
|
||||
"title": "App wiederherstellen",
|
||||
"description": "Wiederherstellen von \"{{ fqdn }}\" aus der Datensicherung, die am {{ creationTime }} erstellt wurde?",
|
||||
"cloneAction": "Klonen",
|
||||
"cloneActionOverwrite": "DNS klonen und DNS überschreiben"
|
||||
},
|
||||
@@ -1392,8 +1414,7 @@
|
||||
"addRedirectionAction": "Eine Weiterleitung hinzufügen",
|
||||
"noAliases": "Keine Aliasse",
|
||||
"addAliasAction": "Alias hinzufügen",
|
||||
"aliases": "Aliasse",
|
||||
"dnsoverwrite": "Einige DNS-Einträge existieren bereits. Mit dem Überschreiben einverstanden."
|
||||
"aliases": "Aliasse"
|
||||
},
|
||||
"updateDialog": {
|
||||
"subscriptionExpired": "Das Cloudron-Abonnement ist abgelaufen. Bitte ein Abonnement einrichten, um die Anwendung zu aktualisieren.",
|
||||
@@ -1405,8 +1426,8 @@
|
||||
"updateAction": "Aktualisieren"
|
||||
},
|
||||
"cloneDialog": {
|
||||
"title": "{{ app }} klonen",
|
||||
"description": "Backup vom <b>{{ creationTime }}</b> und der Version <b>v{{ packageVersion }}</b> verwenden",
|
||||
"title": "App klonen",
|
||||
"description": "Klonen mit der Datensicherung vom <b>{{ creationTime }}</b> (Version <b>{{ packageVersion }}</b>).",
|
||||
"location": "Standort"
|
||||
},
|
||||
"graphs": {
|
||||
@@ -1427,8 +1448,10 @@
|
||||
"title": "Backup importieren",
|
||||
"uploadAction": "Datensicherungskonfiguration hochladen",
|
||||
"importAction": "Importieren",
|
||||
"remotePath": "Backup-Pfad",
|
||||
"provideBackupInfo": "Geben Sie die Datensicherungsinformationen an, von denen wiederhergestellt werden soll, oder"
|
||||
"remotePath": "Datensicherungs-Pfad",
|
||||
"provideBackupInfo": "Geben Sie die Datensicherungsinformationen an, von denen wiederhergestellt werden soll, oder",
|
||||
"warning": "Alle Daten, die seit der letzten Datensicherung erstellt wurden, gehen dauerhaft verloren. Es wird empfohlen, vor dem Import eine neue Datensicherung zu erstellen.",
|
||||
"versionMustMatchInfo": "Die Datensicherung muss mit derselben Paketversion und denselben Zugriffssteuerungseinstellungen wie diese App erstellt worden sein."
|
||||
},
|
||||
"terminalActionTooltip": "Terminal",
|
||||
"filemanagerActionTooltip": "Dateimanager",
|
||||
@@ -1460,8 +1483,8 @@
|
||||
},
|
||||
"title": "Crontab",
|
||||
"saveAction": "Speichern",
|
||||
"addCommonPattern": "Häufige Muster hinzufügen",
|
||||
"description": "Benutzerdefinierte app-spezifische Cronjobs können hier hinzugefügt werden. Beachten Sie, dass Cronjobs, die für das Funktionieren der App erforderlich sind, bereits in das App-Paket integriert sind und hier nicht konfiguriert werden müssen."
|
||||
"addCommonPattern": "Gängiges Muster einfügen",
|
||||
"description": "Für den Betrieb der App erforderliche Cron-Jobs sind bereits im App-Paket integriert. Fügen Sie hier nur zusätzliche Jobs hinzu, die speziell zu Ihrem Setup passen."
|
||||
},
|
||||
"sftpInfoAction": "SFTP Zugang",
|
||||
"cronTabTitle": "Cron",
|
||||
@@ -1479,7 +1502,7 @@
|
||||
},
|
||||
"redis": {
|
||||
"title": "Redis Konfiguration",
|
||||
"info": "Wenn aktiviert, verwendet die App den integrierten Redis-Dienst. Wenn deaktiviert, bleiben die Redis-Einstellungen der App unberührt."
|
||||
"info": "Integrierten Redis-Dienst verwenden. Wenn er deaktiviert ist, bleiben die App-Redis-Einstellungen unverändert."
|
||||
},
|
||||
"infoTabTitle": "Info",
|
||||
"info": {
|
||||
@@ -1489,19 +1512,19 @@
|
||||
},
|
||||
"turn": {
|
||||
"title": "TURN Einstellungen",
|
||||
"info": "Aktivieren Sie diese Option, um die App so zu konfigurieren, dass der integrierte TURN-Server verwendet wird. Wenn deaktiviert, bleiben die TURN-Einstellungen der App unverändert."
|
||||
"info": "Verwenden Sie den eingebauten TURN-Server. Wenn deaktiviert, bleiben die TURN-Einstellungen der App unverändert."
|
||||
},
|
||||
"servicesTabTitle": "Dienste",
|
||||
"archive": {
|
||||
"title": "Archiv",
|
||||
"action": "Archiv",
|
||||
"noBackup": "Diese App hat keine Datensicherung. Archivierung benötigt eine aktuelle Datensicherung.",
|
||||
"description": "Die letzte Datensicherung der App wird dem <a href=\"/#/backups\">Archiv</a> hinzugefügt. Die App wird deinstalliert, aber kann im Datensicherungsbereich wiederhergestellt werden. Die anderen Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
|
||||
"description": "Die letzte Datensicherung der App wird dem <a href=\"/#/app-archive\">Archiv</a> hinzugefügt und die App deinstalliert.",
|
||||
"latestBackupInfo": "Die letzte Datensicherung wurde am {{ date }} erstellt."
|
||||
},
|
||||
"archiveDialog": {
|
||||
"description": "Dies deinstalliert die App und legt die letzte Datensicherung, erstellt am {{ date }} ins Archiv.",
|
||||
"title": "Archiviere {{ app }}"
|
||||
"description": "App deinstallieren und letzte Datensicherung vom {{ date }} ins Archiv legen?",
|
||||
"title": "App archivieren"
|
||||
},
|
||||
"configureTooltip": "Konfigurieren",
|
||||
"updateAvailableTooltip": "Aktualisierung verfügbar",
|
||||
@@ -1516,13 +1539,15 @@
|
||||
"clear": "Anzeige löschen"
|
||||
},
|
||||
"volumes": {
|
||||
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
|
||||
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Apps gemeinsam genutzt werden können.",
|
||||
"removeVolumeDialog": {
|
||||
"removeAction": "Entfernen"
|
||||
"removeAction": "Entfernen",
|
||||
"title": "Datenträger entfernen",
|
||||
"description": "Datenträger \"{{ volumeName }}\" entfernen?"
|
||||
},
|
||||
"addVolumeDialog": {
|
||||
"title": "Datenträger hinzufügen",
|
||||
"server": "Server IP oder Hostname",
|
||||
"server": "Server IP / Hostname",
|
||||
"remoteDirectory": "Remote-Verzeichnis",
|
||||
"username": "Username",
|
||||
"password": "Passwort",
|
||||
@@ -1538,7 +1563,7 @@
|
||||
"localDirectory": "Lokales Verzeichnis",
|
||||
"remountActionTooltip": "Neu einhängen",
|
||||
"editVolumeDialog": {
|
||||
"title": "Datenträger {{ name }} konfigurieren"
|
||||
"title": "Datenträger konfigurieren"
|
||||
},
|
||||
"emptyPlaceholder": "Keine Datenträger"
|
||||
},
|
||||
@@ -1551,7 +1576,7 @@
|
||||
},
|
||||
"storage": {
|
||||
"mounts": {
|
||||
"description": "Eingehängte Datenträger können unter <code>/media/(Datenträgername)</code> zugegriffen werden. Eingehängte Daten werden nicht in der Datensicherung der App erfasst."
|
||||
"description": "Eingehängte Datenträger können unter \"/media/(Datenträgername)\" zugegriffen werden. Eingehängte Daten werden nicht in der Datensicherung der App erfasst."
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
@@ -1560,19 +1585,20 @@
|
||||
"createAction": "Hinzufügen"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"name": "Client Name",
|
||||
"id": "Client ID",
|
||||
"signingAlgorithm": "Signatur Algorithmus",
|
||||
"loginRedirectUri": "Login Callback URLs (mit Komma getrennt)",
|
||||
"secret": "Client Geheimnis"
|
||||
"loginRedirectUri": "Login Callback URLs",
|
||||
"secret": "Client Geheimnis",
|
||||
"loginRedirectUriPlaceholder": "Durch Kommas getrennte URLs"
|
||||
},
|
||||
"description": "OpenID kann von externen Anwendungen für Single Sign-On verwendet werden.",
|
||||
"editClientDialog": {
|
||||
"title": "Client {{ client }} bearbeiten"
|
||||
"title": "Client bearbeiten"
|
||||
},
|
||||
"deleteClientDialog": {
|
||||
"title": "Wirklich Client {{ client }} löschen?",
|
||||
"description": "Wenn dies gelöscht wird, werden alle Tokens dieses OpenID Clients, ungültig gemacht. Damit werden alle externen OpenID Apps, die diese Clientendetails nutzen, getrennt."
|
||||
"title": "Client löschen",
|
||||
"description": "Nach der Löschung werden alle von diesem Client ausgestellten Zugriffstoken ungültig. Apps, die ihn verwenden, können sich nicht mehr authentifizieren.<br/><br/>Client '{{ clientName }}' löschen?"
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "Discovery URL"
|
||||
@@ -1580,6 +1606,10 @@
|
||||
"clients": {
|
||||
"title": "OpenID-Clients",
|
||||
"empty": "Keine OpenID-Clients"
|
||||
},
|
||||
"clientCredentials": {
|
||||
"title": "Client Zugangsdaten",
|
||||
"description": "Zugangsdaten des Clients \"{{ clientName }}\" kopieren"
|
||||
}
|
||||
},
|
||||
"userdirectory": {
|
||||
@@ -1593,22 +1623,25 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Keine archivierten Apps"
|
||||
}
|
||||
},
|
||||
"description": "Archivierte Apps bewahren die neueste Datensicherung auf, wenn sie archiviert wurde. Diese Datensicherungen werden dauerhaft aufbewahrt und können wiederhergestellt werden."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
"label": "Datensicherungsstandort",
|
||||
"size": "Größe"
|
||||
"label": "Standort",
|
||||
"size": "Größe",
|
||||
"fileCount": "Dateien"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Datensicherungsstandorte",
|
||||
"emptyPlaceholder": "Keine Datensicherungsstandorte",
|
||||
"lastRun": "Letzter Lauf"
|
||||
"lastRun": "Letzter Lauf",
|
||||
"description": "Datensicherungsstandorte geben an, wo System- und App-Datensicherungen gespeichert werden. App-Datensicherungen können einzeln wiederhergestellt werden."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
"description": "Dies entfernt auch alle Datensicherungseinträge, die mit diesem Standort verbunden sind.",
|
||||
"title": "Wollen Sie diesen Datensicherungsstandort wirklich entfernen?"
|
||||
"description": "Beim Entfernen eines Datensicherungsstandorts werden dessen zugehörige Datensicherungseinträge von Cloudron gelöscht. Auf dem entfernten Zielort gespeicherte Datensicherungsdateien werden nicht gelöscht.<br/></br>Datensicherungsstandort '{{ name }}' entfernen?",
|
||||
"title": "Datensicherungsstandort entfernen"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1617,17 +1650,21 @@
|
||||
"provider": "Anbieter",
|
||||
"username": "Username",
|
||||
"title": "Docker-Registries",
|
||||
"description": "Cloudron kann benutzerdefinierte Apps aus einer privaten Docker-Registry ziehen und installieren.",
|
||||
"description": "Zugriff auf private Docker-Registries konfigurieren, um benutzerdefinierte Apps zu installieren.",
|
||||
"removeDialog": {
|
||||
"title": "{{ serverAddress }} löschen"
|
||||
"title": "Docker-Registry entfernen"
|
||||
},
|
||||
"email": "E-Mail",
|
||||
"passwordToken": "Passwort/Token",
|
||||
"emptyPlaceholder": "Keine Docker-Registries"
|
||||
"emptyPlaceholder": "Keine Docker-Registries",
|
||||
"dialog": {
|
||||
"addTitle": "Docker-Registry hinzufügen",
|
||||
"editTitle": "Docker-Registry bearbeiten"
|
||||
}
|
||||
},
|
||||
"dockerRegistres": {
|
||||
"removeDialog": {
|
||||
"description": "Möchten Sie diese Registry wirklich entfernen?"
|
||||
"description": "Docker-Registry \"{{ serverAddress }}\" entfernen?"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
"next": "Next",
|
||||
"configure": "Configure",
|
||||
"restart": "Restart",
|
||||
"reset": "Reset"
|
||||
"reset": "Reset",
|
||||
"loadMore": "Load more",
|
||||
"setup": "Set up",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Reboot Server",
|
||||
@@ -104,6 +107,9 @@
|
||||
"appNotFoundDialog": {
|
||||
"title": "App not found",
|
||||
"description": "There is no such app <b>{{ appId }}</b> with version <b>{{ version }}</b>."
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Add custom app"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
@@ -287,14 +293,19 @@
|
||||
"authenticatorAppDescription": "Use Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) or a similar TOTP app to scan the secret.",
|
||||
"token": "Token",
|
||||
"enable": "Enable",
|
||||
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue."
|
||||
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue.",
|
||||
"passkeyOption": "Passkey",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Set up passkey",
|
||||
"passkeyDescription": "The browser will prompt you to create a passkey using your device's biometrics or a password manager."
|
||||
},
|
||||
"appPasswords": {
|
||||
"title": "App Passwords",
|
||||
"app": "App",
|
||||
"name": "Name",
|
||||
"noPasswordsPlaceholder": "No app passwords",
|
||||
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here."
|
||||
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here.",
|
||||
"expires": "Expires"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
@@ -327,7 +338,8 @@
|
||||
"name": "Password name",
|
||||
"app": "App",
|
||||
"description": "Use the following password to authenticate against the app:",
|
||||
"copyNow": "Please copy the password now. It won't be shown again for security purposes."
|
||||
"copyNow": "Please copy the password now. It won't be shown again for security purposes.",
|
||||
"expiresAt": "Expiry date"
|
||||
},
|
||||
"createApiToken": {
|
||||
"title": "Add API Token",
|
||||
@@ -338,8 +350,6 @@
|
||||
"allowedIpRanges": "Allowed IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Change password",
|
||||
"disable2FAAction": "Disable 2FA",
|
||||
"enable2FAAction": "Enable 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email sent to {{ email }}"
|
||||
},
|
||||
@@ -350,6 +360,26 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Remove App Password",
|
||||
"description": "Remove app password \"{{ name }}\" ?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Two-factor authentication",
|
||||
"totpEnabled": "Enabled",
|
||||
"passkeyEnabled": "Enabled",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Not set",
|
||||
"enablePasskey": {
|
||||
"title": "Enable passkey"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Enable TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Disable TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Disable Passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -381,7 +411,10 @@
|
||||
"date": "Created",
|
||||
"version": "Package version",
|
||||
"size": "Size",
|
||||
"duration": "Backup duration"
|
||||
"duration": "Backup duration",
|
||||
"lastIntegrityCheck": "Last integrity check",
|
||||
"integrityNever": "never",
|
||||
"integrityInProgress": "In progress"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "Configure Backup Schedule & Retention",
|
||||
@@ -499,7 +532,9 @@
|
||||
"title": "Configure Backup Content"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "File and filename encryption used",
|
||||
"useFileEncryption": "File encryption used"
|
||||
"useFileEncryption": "File encryption used",
|
||||
"checkIntegrity": "Check integrity",
|
||||
"stopIntegrity": "Stop integrity check"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding",
|
||||
@@ -686,17 +721,16 @@
|
||||
"updateAvailableAction": "Update available",
|
||||
"stopUpdateAction": "Stop update",
|
||||
"disabled": "Disabled",
|
||||
"schedule": "Update schedule",
|
||||
"description": "Platform and app updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest"
|
||||
"description": "Updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest",
|
||||
"config": "Automatic updates",
|
||||
"appsOnly": "Apps only",
|
||||
"platformAndApps": "Platform & apps"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Configure Automatic Update Schedule",
|
||||
"disableCheckbox": "Disable automatic updates",
|
||||
"enableCheckbox": "Enable automatic updates",
|
||||
"selectOne": "Select at least one day and time",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"description": "Set the days and times for automatic platform and app updates. Ensure this schedule doesn’t overlap with backup schedules."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -716,6 +750,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Docker registry provider",
|
||||
"providerOther": "Other"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Configure Automatic Updates",
|
||||
"policy": "Policy",
|
||||
"policyDescription": "Choose what gets updated automatically",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -773,7 +815,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Dashboard Domain",
|
||||
"description": "Change the dashboard to the “my” subdomain of the selected domain",
|
||||
"changeAction": "Change domain"
|
||||
"changeAction": "Change domain",
|
||||
"confirmMessage": "This will invalidate all passkeys for users.",
|
||||
"confirmTitle": "Really change dashboard domain?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"addTitle": "Add Domain",
|
||||
@@ -781,6 +825,8 @@
|
||||
"domain": "Domain",
|
||||
"provider": "DNS provider",
|
||||
"route53AccessKeyId": "Access key ID",
|
||||
"powerdnsApiUrl": "PowerDNS API URL (e.g., https://ns1.example.com:8081)",
|
||||
"powerdnsApiKey": "API Key",
|
||||
"route53SecretAccessKey": "Secret access key",
|
||||
"gcdnsServiceAccountKey": "Service account key",
|
||||
"digitalOceanToken": "DigitalOcean token",
|
||||
@@ -831,7 +877,9 @@
|
||||
"inwxUsername": "INWX username",
|
||||
"inwxPassword": "INWX password",
|
||||
"customNameservers": "Domain uses custom (vanity) nameservers",
|
||||
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain."
|
||||
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain.",
|
||||
"carddavLocation": "CardDAV server location",
|
||||
"caldavLocation": "CalDAV server location"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Remove Domain",
|
||||
@@ -865,12 +913,19 @@
|
||||
"appDown": "App is down",
|
||||
"rebootRequired": "Server reboot required",
|
||||
"cloudronUpdateFailed": "Cloudron update failed",
|
||||
"diskSpace": "Low disk space"
|
||||
"diskSpace": "Low disk space",
|
||||
"appAutoUpdateFailed": "App automatic update failed",
|
||||
"manualUpdateRequired": "Platform or app requires manual update"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "An email will be sent for the selected events to your primary email."
|
||||
},
|
||||
"allCaughtUp": "All caught up"
|
||||
"allCaughtUp": "All caught up",
|
||||
"title": "Notifications",
|
||||
"showAll": "All",
|
||||
"showUnread": "Unread",
|
||||
"markUnread": "Mark as unread",
|
||||
"markRead": "Mark as read"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
@@ -894,11 +949,11 @@
|
||||
"reallyDelete": "Really delete?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "New Folder Name",
|
||||
"title": "New folder",
|
||||
"create": "Create"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "New Filename",
|
||||
"title": "New filename",
|
||||
"create": "Create"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -916,16 +971,17 @@
|
||||
"restartApp": "Restart App",
|
||||
"uploadFolder": "Upload folder",
|
||||
"openTerminal": "Open terminal",
|
||||
"openLogs": "Open logs"
|
||||
"openLogs": "Open logs",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"extractionInProgress": "Extraction in progress",
|
||||
"pasteInProgress": "Pasting in progress",
|
||||
"deleteInProgress": "Deletion in progress",
|
||||
"chownDialog": {
|
||||
"title": "Change ownership",
|
||||
"title": "Change owner",
|
||||
"newOwner": "New owner",
|
||||
"change": "Change Owner",
|
||||
"recursiveCheckbox": "Change ownership recursively"
|
||||
"change": "Change owner",
|
||||
"recursiveCheckbox": "Change owner recursively"
|
||||
},
|
||||
"uploadingDialog": {
|
||||
"title": "Uploading files ({{ countDone }}/{{ count }})",
|
||||
@@ -1176,7 +1232,7 @@
|
||||
"aliases": "Aliases",
|
||||
"addAliasAction": "Add an alias",
|
||||
"noAliases": "No alias domains",
|
||||
"dnsoverwrite": "Some DNS records already exist. Agree to overwrite."
|
||||
"overwriteDns": "Overwrite existing DNS records of {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
@@ -1306,14 +1362,15 @@
|
||||
"packageVersion": "Package version",
|
||||
"lastUpdated": "Last updated",
|
||||
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
|
||||
"installedAt": "Installed"
|
||||
"installedAt": "Installed",
|
||||
"packager": "Packager"
|
||||
},
|
||||
"auto": {
|
||||
"description": "App updates are applied periodically based on the <a href=\"/#/system-update\">update schedule</a>",
|
||||
"title": "Automatic updates"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron automatically checks the App Store for updates. You can also check manually."
|
||||
"description": "Cloudron automatically checks for app updates. You can also check manually."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -1356,11 +1413,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"description": "Apps can be stopped to conserve server resources instead of uninstalling. Future app backups will not include any app changes between now and the most recent app backup. For this reason, it is recommended to trigger a backup before stopping the app.",
|
||||
"startAction": "Start",
|
||||
"stopAction": "Stop"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Uninstall",
|
||||
"description": "Uninstall the app and delete its data. Backups are cleaned up according to the backup policy.",
|
||||
@@ -1472,6 +1524,16 @@
|
||||
"forumAction": "Forum",
|
||||
"appLink": {
|
||||
"title": "External Link"
|
||||
},
|
||||
"start": {
|
||||
"title": "Start",
|
||||
"description": "Start the app to make it available again.",
|
||||
"action": "Start"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Stop",
|
||||
"title": "Stop",
|
||||
"description": "Stop the app to conserve resources. Back up before stopping to preserve recent changes."
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -1482,7 +1544,10 @@
|
||||
"resetPasswordAction": "Reset password",
|
||||
"errorIncorrect2FAToken": "2FA token is invalid",
|
||||
"errorInternal": "Internal error, try again later",
|
||||
"loginAction": "Log in"
|
||||
"loginAction": "Log in",
|
||||
"usePasskeyAction": "Use passkey",
|
||||
"errorPasskeyFailed": "Failed to login with passkey",
|
||||
"passkeyAction": "Log in with a passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Password reset",
|
||||
@@ -1571,7 +1636,8 @@
|
||||
"editVolumeDialog": {
|
||||
"title": "Edit Volume"
|
||||
},
|
||||
"emptyPlaceholder": "No volumes"
|
||||
"emptyPlaceholder": "No volumes",
|
||||
"mountPointDescription": "The mount point has to be set up manually. See <a href=\"{{ docsLink }}\" target=\"_blank\">docs</a>."
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"subject": "[<%= cloudron %>] New login on your account",
|
||||
@@ -1639,7 +1705,8 @@
|
||||
"title": "Backup Sites",
|
||||
"emptyPlaceholder": "No backup sites",
|
||||
"lastRun": "Last run",
|
||||
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually."
|
||||
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually.",
|
||||
"noAutomaticUpdateBackupWarning": "No backup site is configured to store backups for automatic updates. Enable \"Store automatic-update backups here\" on at least one backup site to allow automatic updates."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1678,5 +1745,9 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "Server"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Community apps are not reviewed by Cloudron. Only install apps from trusted developers. Third-party code can compromise your system.",
|
||||
"unstablewarning": "This app is marked as unstable by its developer."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@
|
||||
"email": "Se connecter avec une adresse email",
|
||||
"sso": "Se connecter avec vos identifiants Cloudron",
|
||||
"openid": "Se connecter avec Cloudron OpenID"
|
||||
}
|
||||
},
|
||||
"noMatchesPlaceholder": "Aucune application correspondante"
|
||||
},
|
||||
"main": {
|
||||
"offline": "Cloudron est hors ligne. Reconnexion…",
|
||||
@@ -26,14 +27,26 @@
|
||||
"save": "Sauvegarder",
|
||||
"no": "Non",
|
||||
"yes": "Oui",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"edit": "Editer",
|
||||
"done": "Terminer"
|
||||
},
|
||||
"username": "Nom d'utilisateur",
|
||||
"actions": "Actions",
|
||||
"displayName": "Nom affiché",
|
||||
"action": {
|
||||
"logs": "Journaux",
|
||||
"reboot": "Redémarrer"
|
||||
"reboot": "Redémarrer",
|
||||
"remove": "Supprimer",
|
||||
"edit": "Editer",
|
||||
"add": "Ajouter",
|
||||
"next": "Suivant",
|
||||
"configure": "Configurer",
|
||||
"restart": "Redémarrer",
|
||||
"reset": "Réinitialiser",
|
||||
"loadMore": "Charger plus",
|
||||
"setup": "Installer",
|
||||
"disable": "Désactiver"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Redémarrer maintenant",
|
||||
@@ -47,9 +60,20 @@
|
||||
},
|
||||
"statusEnabled": "Activé",
|
||||
"navbar": {
|
||||
"users": "Utilisateurs"
|
||||
"users": "Utilisateurs",
|
||||
"groups": "Groupes"
|
||||
},
|
||||
"loadingPlaceholder": "Chargement"
|
||||
"loadingPlaceholder": "Chargement",
|
||||
"table": {
|
||||
"version": "Version",
|
||||
"created": "Créé"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Réduire la barre latérale"
|
||||
},
|
||||
"platform": {
|
||||
"startupFailed": "Échec du démarrage de la plateforme"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"users": {
|
||||
@@ -64,17 +88,22 @@
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe",
|
||||
"setGhostTooltip": "Emprunter l'identité",
|
||||
"invitationTooltip": "Inviter",
|
||||
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail"
|
||||
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail",
|
||||
"noMatchesPlaceholder": "Aucun utilisateur correspondant",
|
||||
"emptyPlaceholder": "Aucun utilisateur"
|
||||
},
|
||||
"groups": {
|
||||
"name": "Nom",
|
||||
"users": "Utilisateurs",
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe"
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe",
|
||||
"emptyPlaceholder": "Aucun groupe",
|
||||
"noMatchesPlaceholder": "Aucun groupe correspondant"
|
||||
},
|
||||
"settings": {
|
||||
"allowProfileEditCheckbox": "Autoriser les utilisateurs à modifier leur nom et leur adresse email",
|
||||
"saveAction": "Enregistrer",
|
||||
"require2FACheckbox": "Demander aux utilisateurs une authentification à deux facteurs (2FA)"
|
||||
"require2FACheckbox": "Demander aux utilisateurs une authentification à deux facteurs (2FA)",
|
||||
"title": "Paramètres"
|
||||
},
|
||||
"externalLdap": {
|
||||
"configureAction": "Paramétrer",
|
||||
@@ -128,7 +157,8 @@
|
||||
"group": {
|
||||
"users": "Utilisateurs",
|
||||
"name": "Nom",
|
||||
"addGroupAction": "Ajouter un groupe"
|
||||
"addGroupAction": "Ajouter un groupe",
|
||||
"allowedApps": "Applications autorisées"
|
||||
},
|
||||
"deleteGroupDialog": {
|
||||
"title": "Supprimer le groupe {{ name }}",
|
||||
@@ -191,7 +221,14 @@
|
||||
"placeholder": "Adresse IP séparée par ligne ou sous-réseau",
|
||||
"label": "Accès restreint"
|
||||
},
|
||||
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP"
|
||||
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP",
|
||||
"enable": "Activer le serveur LDAP",
|
||||
"title": "Serveur LDAP",
|
||||
"enabled": "Activer le serveur LDAP"
|
||||
},
|
||||
"title": "Utilisateurs",
|
||||
"2FAResetDialog": {
|
||||
"title": "Réinitialiser l'authentification à deux facteurs de l'utilisateur"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
@@ -216,7 +253,8 @@
|
||||
"name": "Nom",
|
||||
"noPasswordsPlaceholder": "Aucun mot de passe d'application créé",
|
||||
"title": "Mots de passe d'application",
|
||||
"description": "Les mots de passe d'application sont une mesure de sécurité pour protéger votre compte utilisateur Cloudron. Si vous avez besoin d'accéder à une application Cloudron depuis une application mobile ou un client auquel vous ne faites pas confiance, vous pouvez vous connecter avec votre nom d'utilisateur et le mot de passe alternatif généré ici."
|
||||
"description": "Les mots de passe d'application sont une mesure de sécurité pour protéger votre compte utilisateur Cloudron. Si vous avez besoin d'accéder à une application Cloudron depuis une application mobile ou un client auquel vous ne faites pas confiance, vous pouvez vous connecter avec votre nom d'utilisateur et le mot de passe alternatif généré ici.",
|
||||
"expires": "Date d'expiration"
|
||||
},
|
||||
"changeEmail": {
|
||||
"title": "Modifier l'adresse email principale",
|
||||
@@ -228,7 +266,8 @@
|
||||
"app": "Application",
|
||||
"name": "Nom du mot de passe",
|
||||
"title": "Créer un mot de passe d'application",
|
||||
"description": "Utilisez le mot de passe suivant pour vous authentifier auprès de l'application :"
|
||||
"description": "Utilisez le mot de passe suivant pour vous authentifier auprès de l'application :",
|
||||
"expiresAt": "Date d'expiration"
|
||||
},
|
||||
"changeFallbackEmail": {
|
||||
"title": "Modifier l'adresse email de récupération du mot de passe"
|
||||
@@ -237,14 +276,20 @@
|
||||
"token": "Jeton",
|
||||
"title": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"enable": "Activer",
|
||||
"authenticatorAppDescription": "Scannez le code avec Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou une application d'authentification similaire."
|
||||
"authenticatorAppDescription": "Scannez le code avec Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou une application d'authentification similaire.",
|
||||
"mandatorySetup": "L'authentification à deux facteurs (2FA) est requise pour accéder au tableau de bord. Veuillez terminer la configuration pour continuer.",
|
||||
"passkeyOption": "Clé d'accès",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Installer une clé d'accès",
|
||||
"passkeyDescription": "Le navigateur vous invitera à créer une clé d'accès à l'aide des données biométriques de votre appareil ou d'un gestionnaire de mots de passe."
|
||||
},
|
||||
"createApiToken": {
|
||||
"name": "Nom du jeton API",
|
||||
"description": "Nouveau jeton API :",
|
||||
"title": "Créer un jeton API",
|
||||
"copyNow": "Veillez à copier le jeton API maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
|
||||
"access": "Accès API"
|
||||
"access": "Accès API",
|
||||
"allowedIpRanges": "Plage(s) d'adresses IP autorisées"
|
||||
},
|
||||
"changePasswordAction": "Modifier le mot de passe",
|
||||
"apiTokens": {
|
||||
@@ -256,17 +301,43 @@
|
||||
"lastUsed": "Dernière utilisation",
|
||||
"scope": "Portée",
|
||||
"readonly": "Lecture seule",
|
||||
"readwrite": "Lecture et écriture"
|
||||
"readwrite": "Lecture et écriture",
|
||||
"allowedIpRangesPlaceholder": "Adresses IP ou sous-réseaux séparés par des virgules",
|
||||
"allowedIpRanges": "Adresses IP autorisées"
|
||||
},
|
||||
"loginTokens": {
|
||||
"logoutAll": "Déconnecter de tous",
|
||||
"title": "Jetons de connexion",
|
||||
"description": "Vous avez {{ webadminTokens.length }} jeton(s) web actif(s) et {{ cliTokens.length }} jeton(s) CLI."
|
||||
},
|
||||
"disable2FAAction": "Désactiver l'authentification à deux facteurs (2FA)",
|
||||
"enable2FAAction": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email envoyé à {{ email }}"
|
||||
},
|
||||
"removeApiToken": {
|
||||
"title": "Supprimer le jeton API"
|
||||
},
|
||||
"removeAppPassword": {
|
||||
"title": "Supprimer le mot de passe de l'application"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Authentification à deux facteurs",
|
||||
"totpEnabled": "Activé",
|
||||
"passkeyEnabled": "Activé",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Clé d'accès"
|
||||
},
|
||||
"notSet": "Non défini",
|
||||
"enablePasskey": {
|
||||
"title": "Activer la clé d'accès"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Activer le TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Désactiver le TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Désactiver la clé d'accès"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -278,7 +349,9 @@
|
||||
"days": "Jours",
|
||||
"hours": "Heures",
|
||||
"title": "Paramétrer la planification et la conservation des sauvegardes",
|
||||
"retentionPolicy": "Politique de conservation"
|
||||
"retentionPolicy": "Politique de conservation",
|
||||
"disable": "Désactiver les sauvegardes automatiques",
|
||||
"enable": "Activer les sauvegardes automatiques"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Planification et conservation",
|
||||
@@ -326,13 +399,36 @@
|
||||
"port": "Port",
|
||||
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
|
||||
"chown": "Le système de fichiers distant prend en charge chown",
|
||||
"encryptFilenames": "Chiffré les nom de fichiers"
|
||||
"encryptFilenames": "Chiffré les nom de fichiers",
|
||||
"preserveAttributesLabel": "Conserver les attributs du fichier",
|
||||
"name": "Nom",
|
||||
"encryptionHint": "Indice pour le mot de passe de chiffrement",
|
||||
"usesEncryption": "La sauvegarde est chiffrée",
|
||||
"useForUpdates": "Enregistrer ici les sauvegardes des mises à jour automatiques",
|
||||
"backupContents": {
|
||||
"title": "Contenu de la sauvegarde",
|
||||
"description": "Choisissez les éléments à sauvegarder sur ce site.",
|
||||
"everything": "Tout",
|
||||
"excludeSelected": "Exclure les éléments sélectionnés",
|
||||
"includeOnlySelected": "N'inclure que les éléments sélectionnés"
|
||||
},
|
||||
"automaticUpdates": {
|
||||
"title": "Sauvegardes des mises à jour automatiques"
|
||||
},
|
||||
"useEncryption": "Chiffrer les sauvegardes",
|
||||
"regionHelperText": "Par défaut \"us-east-1\" si laissé vide",
|
||||
"prefixHelperText": "Les sauvegardes sont stockées dans ce sous-dossier"
|
||||
},
|
||||
"backupDetails": {
|
||||
"title": "Informations sur la sauvegarde",
|
||||
"id": "ID",
|
||||
"date": "Date",
|
||||
"version": "Version"
|
||||
"version": "Version",
|
||||
"size": "Taille",
|
||||
"duration": "Durée de la sauvegarde",
|
||||
"lastIntegrityCheck": "Dernier contrôle d'intégrité",
|
||||
"integrityNever": "Jamais",
|
||||
"integrityInProgress": "En cours"
|
||||
},
|
||||
"listing": {
|
||||
"title": "Liste",
|
||||
@@ -354,12 +450,45 @@
|
||||
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
|
||||
},
|
||||
"remotePath": "Chemin d'accès à distance"
|
||||
}
|
||||
},
|
||||
"archives": {
|
||||
"title": "Archive de l'application",
|
||||
"info": "Information"
|
||||
},
|
||||
"deleteArchiveDialog": {
|
||||
"title": "Supprimer l'archive"
|
||||
},
|
||||
"deleteArchive": {
|
||||
"deleteAction": "Supprimer"
|
||||
},
|
||||
"restoreArchiveDialog": {
|
||||
"title": "Restaurer à partir de l'archive",
|
||||
"restoreAction": "Restaurer",
|
||||
"restoreActionOverwrite": "Restaurer et écraser le DNS"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites"
|
||||
},
|
||||
"site": {
|
||||
"addDialog": {
|
||||
"title": "Ajouter un site de sauvegarde"
|
||||
}
|
||||
},
|
||||
"configAction": "Configuration",
|
||||
"contentAction": "Contenu",
|
||||
"configureContent": {
|
||||
"title": "Configurer le contenu de la sauvegarde"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Chiffrement des fichiers et des noms de fichiers utilisé",
|
||||
"useFileEncryption": "Chiffrement des fichiers utilisé",
|
||||
"checkIntegrity": "Vérifier l'intégrité",
|
||||
"stopIntegrity": "Arrêter le contrôle d'intégrité"
|
||||
},
|
||||
"emails": {
|
||||
"title": "Messagerie",
|
||||
"changeDomainDialog": {
|
||||
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi."
|
||||
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi.",
|
||||
"setAction": "Définir l'emplacement"
|
||||
},
|
||||
"eventlog": {
|
||||
"details": "Détails",
|
||||
@@ -380,7 +509,9 @@
|
||||
"bounceInfo": "Notification d'email non distribué",
|
||||
"underQuotaInfo": "La boîte mail {{ mailbox }} est passée sous le quota de {{ quotaPercent }}%",
|
||||
"overQuotaInfo": "La boîte mail {{ mailbox }} est pleine à {{ quotaPercent }}%",
|
||||
"quota": "Quota de boîte mail"
|
||||
"quota": "Quota de boîte mail",
|
||||
"savedInfo": "Enregistré",
|
||||
"sentInfo": "Envoyé"
|
||||
},
|
||||
"title": "Journal des événements de la messagerie",
|
||||
"mailFrom": "De",
|
||||
@@ -402,7 +533,8 @@
|
||||
"title": "Domaines",
|
||||
"outbound": "Sortant uniquement",
|
||||
"stats": "{{ mailboxCount }} adresse(s) de messagerie / utilisation : {{ usage }}",
|
||||
"testEmailTooltip": "Envoyer un email test"
|
||||
"testEmailTooltip": "Envoyer un email test",
|
||||
"inbound": "Entrant et sortant"
|
||||
},
|
||||
"testMailDialog": {
|
||||
"title": "Envoyer un email test pour {{ domain }}",
|
||||
@@ -496,7 +628,12 @@
|
||||
"setupAction": "Créer un compte",
|
||||
"description": "Un compte Cloudron.io permet d'accéder à l'App Store et de gérer votre abonnement.",
|
||||
"title": "Compte Cloudron.io",
|
||||
"emailNotVerified": "Adresse email pas encore confirmée"
|
||||
"emailNotVerified": "Adresse email pas encore confirmée",
|
||||
"account": "Compte",
|
||||
"unlinkAction": "Dissocier le compte",
|
||||
"unlinkDialog": {
|
||||
"title": "Désassocier le compte Cloudron.io"
|
||||
}
|
||||
},
|
||||
"registryConfig": {
|
||||
"provider": "Fournisseur du registre Docker",
|
||||
@@ -517,22 +654,32 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Sélectionnez les jours et heures de lancement des mises à jour de la plateforme et des applications. Veillez à ne pas planifier les mises à jour au même moment que la <a href=\"/#/backups\">sauvegarde</a>.",
|
||||
"hours": "Heures",
|
||||
"days": "Jours",
|
||||
"selectOne": "Sélectionnez au moins un jour et une heure",
|
||||
"enableCheckbox": "Activer les mises à jour automatiques",
|
||||
"disableCheckbox": "Désactiver les mises à jour automatiques",
|
||||
"title": "Planification des mises à jour automatiques"
|
||||
"disableCheckbox": "Désactiver les mises à jour automatiques"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Interrompre la mise à jour",
|
||||
"updateAvailableAction": "Mise à jour disponible",
|
||||
"checkForUpdatesAction": "Rechercher les mises à jour disponibles",
|
||||
"title": "Mises à jour"
|
||||
"title": "Mises à jour",
|
||||
"disabled": "Désactivé",
|
||||
"onLatest": "dernier",
|
||||
"config": "Mises à jour automatiques",
|
||||
"appsOnly": "Applications uniquement",
|
||||
"platformAndApps": "Plateforme et applications"
|
||||
},
|
||||
"timezone": {
|
||||
"title": "Fuseau horaire",
|
||||
"description": "Le fuseau horaire défini actuellement est le suivant : <b>{{ timeZone }}</b>.\nCe paramètre est utilisé pour la planification des opérations de sauvegarde et de mise à jour."
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Configurer les mises à jour automatiques",
|
||||
"policy": "Stratégie",
|
||||
"policyDescription": "Choisissez ce qui doit être mis à jour automatiquement",
|
||||
"days": "Jours",
|
||||
"hours": "Heures",
|
||||
"schedule": "Planifier"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -543,7 +690,28 @@
|
||||
},
|
||||
"notifications": {
|
||||
"dismissTooltip": "Supprimer",
|
||||
"markAllAsRead": "Tout marquer comme lu"
|
||||
"markAllAsRead": "Tout marquer comme lu",
|
||||
"settings": {
|
||||
"title": "Paramètres de notification",
|
||||
"backupFailed": "Échec de la sauvegarde",
|
||||
"certificateRenewalFailed": "Échec du renouvellement du certificat",
|
||||
"appOutOfMemory": "L'application manque de mémoire",
|
||||
"appUp": "L'application est de nouveau disponible",
|
||||
"appDown": "L'application est hors service",
|
||||
"rebootRequired": "Un redémarrage du serveur est nécessaire",
|
||||
"cloudronUpdateFailed": "Échec de la mise à jour de Cloudron",
|
||||
"diskSpace": "Espace disque faible",
|
||||
"appAutoUpdateFailed": "Échec de la mise à jour automatique de l'application",
|
||||
"manualUpdateRequired": "La plateforme ou l'application nécessite une mise à jour manuelle"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "Un e-mail contenant les événements sélectionnés vous sera envoyé à votre adresse e-mail principale."
|
||||
},
|
||||
"title": "Notifications",
|
||||
"showAll": "Tout",
|
||||
"showUnread": "Non lu",
|
||||
"markUnread": "Marquer comme non lu",
|
||||
"markRead": "Marquer comme lu"
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
@@ -573,10 +741,14 @@
|
||||
"userManagementLeaveToApp": "Laisser la gestion des utilisateurs à l'application",
|
||||
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu",
|
||||
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé pour que le domaine de l'application puisse accéder à ce port",
|
||||
"portReadOnly": "lecture seule"
|
||||
"portReadOnly": "lecture seule",
|
||||
"ephemeralPortWarning": "L'utilisation de ports éphémères peut entraîner des conflits imprévisibles."
|
||||
},
|
||||
"unstable": "Instable",
|
||||
"searchPlaceholder": "Rechercher des solutions alternatives telles que GitHub, Dropbox, Slack, Trello…"
|
||||
"searchPlaceholder": "Rechercher des solutions alternatives telles que GitHub, Dropbox, Slack, Trello…",
|
||||
"action": {
|
||||
"addCustomApp": "Ajouter une application personnalisée"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"updatesTabTitle": "Mises à jour",
|
||||
@@ -586,7 +758,14 @@
|
||||
"lastUpdated": "Dernière mise à jour",
|
||||
"packageVersion": "Version du package",
|
||||
"appId": "ID de l'application",
|
||||
"description": "Nom et version de l'application"
|
||||
"description": "Nom et version de l'application",
|
||||
"installedAt": "Installé"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Mises à jour automatiques"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron vérifie automatiquement si des mises à jour sont disponibles pour les applications. Vous pouvez également les vérifier manuellement."
|
||||
}
|
||||
},
|
||||
"backupsTabTitle": "Sauvegardes",
|
||||
@@ -614,10 +793,27 @@
|
||||
"csp": {
|
||||
"saveAction": "Enregistrer",
|
||||
"description": "Le paramétrage de cette option écrasera tous les en-têtes CSP générés par l'application elle-même.",
|
||||
"title": "Politique de sécurité du contenu (CSP)"
|
||||
"title": "Politique de sécurité du contenu (CSP)",
|
||||
"insertCommonCsp": "Insérer un CSP standard",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Autoriser l'intégration",
|
||||
"sameOriginEmbedding": "Autoriser l'intégration (uniquement les sous-domaines)",
|
||||
"allowCdnAssets": "Autoriser les ressources CDN",
|
||||
"reportOnly": "Signaler les violations du CSP",
|
||||
"strictBaseline": "Référence stricte"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt"
|
||||
"title": "Robots.txt",
|
||||
"description": "Par défaut, les robots peuvent indexer cette application",
|
||||
"commonPattern": {
|
||||
"allowAll": "Tout autoriser (par défaut)",
|
||||
"disallowAll": "Tout interdire",
|
||||
"disallowCommonBots": "Bloquer les robots courants",
|
||||
"disallowAdminPaths": "Interdire les chemins d'accès à l'administration",
|
||||
"disallowApiPaths": "Interdire les chemins d'accès à l'API"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Insérer un fichier robots.txt standard"
|
||||
},
|
||||
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
|
||||
},
|
||||
@@ -647,18 +843,27 @@
|
||||
"operators": {
|
||||
"title": "Opérateurs",
|
||||
"description": "Les opérateurs peuvent configurer et assurer la maintenance de cette application."
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Définissez qui peut voir cette application sur le tableau de bord."
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"recovery": {
|
||||
"description": "Si l'application ne répond pas, essayez de redémarrer l'application. Si l'application redémarre sans arrêt à cause d'un plugin défectueux ou d'une anomalie de paramétrage, mettez l'application en mode récupération pour avoir accès à la console. \nSuivez les <a href=\"{{ docsLink }}\" target=\"_blank\">instructions suivantes</a> pour faire fonctionner l'application à nouveau.",
|
||||
"restartAction": "Redémarrer l'application",
|
||||
"title": "Récupération après un crash"
|
||||
"title": "Récupération après un crash",
|
||||
"disableAction": "Désactiver le mode de récupération",
|
||||
"enableAction": "Activer le mode de récupération"
|
||||
},
|
||||
"taskError": {
|
||||
"retryAction": "Relancer l'opération {{ task }}",
|
||||
"description": "Si une action de paramétrage, de mise à jour, de restauration ou de sauvegarde échoue, vous pouvez relancer l'opération.",
|
||||
"title": "Erreur de tâche"
|
||||
},
|
||||
"restart": {
|
||||
"title": "Redémarrer",
|
||||
"description": "Si l'application ne répond pas, essayez de la redémarrer."
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
@@ -690,13 +895,18 @@
|
||||
"warning": "Toutes les données créées depuis la dernière sauvegarde connue seront définitivement perdues. Il est fortement recommandé de sauvegarder les données actuelles avant de lancer une restauration.",
|
||||
"description": "Cette action entraînera la restauration de l'application à partir des données de {{ creationTime }}.",
|
||||
"title": "Restaurer {{ app }}",
|
||||
"restoreAction": "Restaurer"
|
||||
"restoreAction": "Restaurer",
|
||||
"cloneAction": "Cloner",
|
||||
"cloneActionOverwrite": "Cloner et écraser le DNS"
|
||||
},
|
||||
"importBackupDialog": {
|
||||
"uploadAction": "Charger le fichier de configuration de la sauvegarde",
|
||||
"title": "Importer la sauvegarde",
|
||||
"importAction": "Importer",
|
||||
"remotePath": "Chemin de la sauvegarde"
|
||||
"remotePath": "Chemin de la sauvegarde",
|
||||
"provideBackupInfo": "Indiquez les informations de sauvegarde à partir desquelles effectuer la restauration, ou",
|
||||
"warning": "Toutes les données créées depuis la dernière sauvegarde seront définitivement perdues. Il est recommandé de créer une nouvelle sauvegarde avant l'importation.",
|
||||
"versionMustMatchInfo": "La sauvegarde doit avoir été créée à l'aide de la même version du package et des mêmes paramètres de contrôle d'accès que cette application."
|
||||
},
|
||||
"repairTabTitle": "Réparation",
|
||||
"uninstallDialog": {
|
||||
@@ -706,7 +916,10 @@
|
||||
},
|
||||
"appInfo": {
|
||||
"package": "Package",
|
||||
"openAction": "Ouvrir {{ app }}"
|
||||
"openAction": "Ouvrir {{ app }}",
|
||||
"checklist": "Liste de contrôle pour l'administrateur",
|
||||
"checklistShow": "Afficher la liste de contrôle",
|
||||
"checklistHide": "Cacher la liste de contrôle"
|
||||
},
|
||||
"firstTimeSetupAction": "Initialisation",
|
||||
"uninstall": {
|
||||
@@ -714,11 +927,6 @@
|
||||
"description": "Cette action entraînera la désinstallation immédiate de l'application et la suppression de l'ensemble de ses données. Le site sera inaccessible.",
|
||||
"uninstallAction": "Désinstaller",
|
||||
"title": "Désinstaller"
|
||||
},
|
||||
"startStop": {
|
||||
"description": "Pour économiser les ressources du serveur, vous pouvez mettre en pause les applications au lieu de les désinstaller. Les futures sauvegardes d'applications ne comprendront pas les modifications apportées aux applications entre aujourd'hui et la dernière sauvegarde. Pour cette raison, il est recommandé de lancer une sauvegarde avant de mettre une application en pause.",
|
||||
"stopAction": "Arrêter l'application",
|
||||
"startAction": "Démarrer l'application"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -738,7 +946,8 @@
|
||||
"downloadConfigTooltip": "Télécharger le fichier de configuration de la sauvegarde",
|
||||
"description": "Les sauvegardes sont des instantanés complets de l'application. Vous pouvez utiliser les sauvegardes pour restaurer ou cloner l'application.",
|
||||
"title": "Sauvegardes",
|
||||
"downloadBackupTooltip": "Télécharger la sauvegarde"
|
||||
"downloadBackupTooltip": "Télécharger la sauvegarde",
|
||||
"checkIntegrity": "Vérifier l'intégrité"
|
||||
}
|
||||
},
|
||||
"graphs": {
|
||||
@@ -747,7 +956,9 @@
|
||||
"7d": "7 jours",
|
||||
"24h": "24 heures",
|
||||
"12h": "12 heures",
|
||||
"6h": "6 heures"
|
||||
"6h": "6 heures",
|
||||
"live": "En direct",
|
||||
"1h": "1 heure"
|
||||
},
|
||||
"diskIOTotal": "total: lecture {{ read }} / écriture {{ write }}",
|
||||
"networkIOTotal": "total : entrant {{ inbound }} / sortant {{ outbound }}"
|
||||
@@ -762,6 +973,10 @@
|
||||
"description": "Taux limite d'utilisation du microprocesseur lorsque le système est très sollicité.",
|
||||
"title": "Utilisation du microprocesseur",
|
||||
"setAction": "Valider"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Appareils",
|
||||
"description": "Liste des appareils connectés à l'application, séparés par des virgules"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
@@ -831,10 +1046,42 @@
|
||||
},
|
||||
"servicesTabTitle": "Services",
|
||||
"turn": {
|
||||
"title": "Configuration de TURN"
|
||||
"title": "Configuration de TURN",
|
||||
"info": "Utilisez le serveur TURN intégré. Si cette option est désactivée, les paramètres TURN de l'application restent inchangés."
|
||||
},
|
||||
"redis": {
|
||||
"title": "Configuration de Redis"
|
||||
"title": "Configuration de Redis",
|
||||
"info": "Utilisez le service Redis intégré. Si cette option est désactivée, les paramètres Redis de l'application restent inchangés."
|
||||
},
|
||||
"infoTabTitle": "Informations",
|
||||
"info": {
|
||||
"notes": {
|
||||
"title": "Notes de l'administrateur"
|
||||
}
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archives",
|
||||
"action": "Archives",
|
||||
"noBackup": "Cette application ne dispose pas de sauvegarde. L'archivage nécessite une sauvegarde récente."
|
||||
},
|
||||
"archiveDialog": {
|
||||
"title": "Application d'archivage"
|
||||
},
|
||||
"updateAvailableTooltip": "Mise à jour disponible",
|
||||
"configureTooltip": "Configurer",
|
||||
"forumAction": "Forum",
|
||||
"appLink": {
|
||||
"title": "Lien externe"
|
||||
},
|
||||
"start": {
|
||||
"title": "Démarrer",
|
||||
"description": "Lancez l'application pour qu'elle soit à nouveau disponible.",
|
||||
"action": "Démarrer"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Arrêter",
|
||||
"title": "Arrêter",
|
||||
"description": "Fermez l'application pour économiser les ressources. Sauvegardez vos données avant de fermer l'application afin de conserver les modifications récentes."
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -846,7 +1093,8 @@
|
||||
"name": "Nom",
|
||||
"description": "Les volumes sont des systèmes de fichiers locaux ou distants. Ils peuvent être utilisés comme stockage de données principal d'une application ou comme emplacement de stockage partagé entre les applications.",
|
||||
"removeVolumeDialog": {
|
||||
"removeAction": "Supprimer"
|
||||
"removeAction": "Supprimer",
|
||||
"title": "Supprimer le volume"
|
||||
},
|
||||
"addVolumeDialog": {
|
||||
"title": "Ajouter un volume",
|
||||
@@ -873,7 +1121,9 @@
|
||||
"description": "Le texte ci-dessous s'affichera dans tous les emails sortants de ce domaine.",
|
||||
"plainTextFormat": "Format texte",
|
||||
"htmlFormat": "Format HTML (optionnel)",
|
||||
"title": "Signature"
|
||||
"title": "Signature",
|
||||
"customSignatureSet": "Signature personnalisée configurée",
|
||||
"noSignatureSet": "Aucune signature configurée"
|
||||
},
|
||||
"incoming": {
|
||||
"catchall": {
|
||||
@@ -886,7 +1136,9 @@
|
||||
"title": "Listes de diffusion",
|
||||
"name": "Nom",
|
||||
"everyoneTooltip": "Utilisation de la liste autorisée aux non-membres",
|
||||
"membersOnlyTooltip": "Utilisation de la liste limitée à ses membres"
|
||||
"membersOnlyTooltip": "Utilisation de la liste limitée à ses membres",
|
||||
"emptyPlaceholder": "Pas de listes de diffusion",
|
||||
"noMatchesPlaceholder": "Aucune liste de diffusion correspondante"
|
||||
},
|
||||
"mailboxes": {
|
||||
"usage": "Utilisation",
|
||||
@@ -894,7 +1146,9 @@
|
||||
"title": "Messageries",
|
||||
"owner": "Propriétaire",
|
||||
"name": "Nom",
|
||||
"addAction": "Ajouter"
|
||||
"addAction": "Ajouter",
|
||||
"emptyPlaceholder": "Pas de boîtes aux lettres",
|
||||
"noMatchesPlaceholder": "Aucune boîte aux lettres correspondante"
|
||||
},
|
||||
"sieveServerInfo": "ManageSieve",
|
||||
"incomingServerInfo": "Réception (IMAP)",
|
||||
@@ -905,7 +1159,8 @@
|
||||
"howToConnectDescription": "Utilisez les paramètres ci-dessous pour configurer les clients de messagerie.",
|
||||
"incomingUserInfo": "Identifiant",
|
||||
"incomingPasswordInfo": "Mot de passe",
|
||||
"incomingPasswordUsage": "Mot de passe du propriétaire de la boîte mail"
|
||||
"incomingPasswordUsage": "Mot de passe du propriétaire de la boîte mail",
|
||||
"description": "Recevoir les e-mails entrants pour ce domaine"
|
||||
},
|
||||
"addMailinglistDialog": {
|
||||
"members": "Liste des membres",
|
||||
@@ -921,7 +1176,8 @@
|
||||
},
|
||||
"addMailboxDialog": {
|
||||
"title": "Ajouter une adresse de messagerie",
|
||||
"name": "Nom"
|
||||
"name": "Nom",
|
||||
"incomingDisabledWarning": "La réception des e-mails pour ce domaine n'est pas activée"
|
||||
},
|
||||
"editMailboxDialog": {
|
||||
"title": "Paramétrer l'adresse de messagerie {{ name }}@{{ domain }}",
|
||||
@@ -947,7 +1203,9 @@
|
||||
},
|
||||
"smtpStatus": {
|
||||
"notBlacklisted": "L'adresse IP de ce serveur {{ ip }} <b>n'est pas</b> sur liste noire.",
|
||||
"blacklisted": "L'adresse IP de ce serveur {{ ip }} est sur liste noire."
|
||||
"blacklisted": "L'adresse IP de ce serveur {{ ip }} est sur liste noire.",
|
||||
"outboundSmtp": "SMTP sortant",
|
||||
"rblCheck": "Vérification de la liste noire DNS"
|
||||
},
|
||||
"dnsStatus": {
|
||||
"recordNotSet": "non défini",
|
||||
@@ -982,7 +1240,13 @@
|
||||
},
|
||||
"config": {
|
||||
"title": "Configuration de la messagerie {{ domain }}",
|
||||
"clientConfiguration": "Configuration des clients de messagerie"
|
||||
"clientConfiguration": "Configuration des clients de messagerie",
|
||||
"sending": {
|
||||
"title": "Envoi"
|
||||
},
|
||||
"receiving": {
|
||||
"title": "Réception"
|
||||
}
|
||||
},
|
||||
"editMailinglistDialog": {
|
||||
"title": "Modifier la liste de diffusion {{ name }}@{{ domain }}"
|
||||
@@ -994,7 +1258,11 @@
|
||||
"enablePop3": "Activer l'accès POP3",
|
||||
"activeCheckbox": "L'adresse de messagerie est active"
|
||||
},
|
||||
"howToConnectInfoModal": "Configuration des clients de messagerie"
|
||||
"howToConnectInfoModal": "Configuration des clients de messagerie",
|
||||
"customFrom": {
|
||||
"title": "Autoriser les adresses d'expéditeur personnalisées",
|
||||
"description": "Autoriser les utilisateurs et les applications authentifiés à utiliser n'importe quelle adresse d'expéditeur"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"syncDns": {
|
||||
@@ -1050,12 +1318,24 @@
|
||||
"bunnyAccessKey": "Bunny Access Key",
|
||||
"ovhConsumerKey": "Consumer Key",
|
||||
"ovhAppKey": "Application Key",
|
||||
"ovhAppSecret": "Application Secret"
|
||||
"ovhAppSecret": "Application Secret",
|
||||
"deSecToken": "jeton deSEC",
|
||||
"gandiTokenType": "Type de jeton",
|
||||
"gandiTokenTypeApiKey": "Clé API (obsolète)",
|
||||
"gandiTokenTypePAT": "Jeton d'accès personnel (PAT)",
|
||||
"inwxUsername": "Nom d'utilisateur INWX",
|
||||
"inwxPassword": "Mot de passe INWX",
|
||||
"customNameservers": "Le domaine utilise des serveurs de noms personnalisés (vanity)",
|
||||
"zoneNamePlaceholder": "Facultatif. Si ce paramètre n'est pas fourni, la valeur par défaut est le domaine racine.",
|
||||
"carddavLocation": "Emplacement du serveur CardDAV",
|
||||
"caldavLocation": "Emplacement du serveur CalDAV"
|
||||
},
|
||||
"changeDashboardDomain": {
|
||||
"description": "Cette action entraînera le déplacement du tableau de bord vers le sous-domaine <code>my</code> du domaine sélectionné.",
|
||||
"changeAction": "Changer le domaine",
|
||||
"title": "Changer le domaine du tableau de bord"
|
||||
"title": "Changer le domaine du tableau de bord",
|
||||
"confirmMessage": "Cela invalidera toutes les clés d'accès des utilisateurs.",
|
||||
"confirmTitle": "Voulez-vous vraiment modifier le domaine du tableau de bord ?"
|
||||
},
|
||||
"removeDialog": {
|
||||
"removeAction": "Supprimer",
|
||||
@@ -1068,7 +1348,14 @@
|
||||
},
|
||||
"provider": "Fournisseur",
|
||||
"domain": "Domaine",
|
||||
"title": "Domaines et Certificats"
|
||||
"title": "Domaines et Certificats",
|
||||
"emptyPlaceholder": "Aucun domaine",
|
||||
"noMatchesPlaceholder": "Aucun domaine correspondant",
|
||||
"description": "L'ajout d'un domaine vous permet d'installer des applications sur ses sous-domaines.",
|
||||
"wellknown": {
|
||||
"editAction": "URI courants",
|
||||
"title": "URI courants"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"footer": {
|
||||
@@ -1076,7 +1363,8 @@
|
||||
},
|
||||
"title": "Affichage",
|
||||
"cloudronName": "Nom du Cloudron",
|
||||
"logo": "Logo"
|
||||
"logo": "Logo",
|
||||
"backgroundImage": "Arrière-plan de la page de connexion"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"subject": "Réinitialisation du mot de passe [<%= cloudron %>]",
|
||||
@@ -1125,7 +1413,8 @@
|
||||
"new": "Nouveau",
|
||||
"uploadFolder": "Charger un dossier",
|
||||
"openTerminal": "Ouvrir le terminal",
|
||||
"openLogs": "Afficher les journaux"
|
||||
"openLogs": "Afficher les journaux",
|
||||
"refresh": "Actualiser"
|
||||
},
|
||||
"renameDialog": {
|
||||
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant ?",
|
||||
@@ -1219,7 +1508,9 @@
|
||||
"downloadAction": "Télécharger",
|
||||
"scheduler": "Planificateur/Cron",
|
||||
"download": {
|
||||
"download": "Télécharger"
|
||||
"download": "Télécharger",
|
||||
"title": "Télécharger le fichier",
|
||||
"description": "Indiquez le chemin d'accès d'un fichier ou d'un répertoire à télécharger depuis le système de fichiers de l'application."
|
||||
},
|
||||
"title": "Terminal"
|
||||
},
|
||||
@@ -1245,10 +1536,19 @@
|
||||
"product": "Produit",
|
||||
"memory": "Mémoire",
|
||||
"uptime": "Durée de fonctionnement",
|
||||
"activationTime": "Heure de création de Cloudron"
|
||||
"activationTime": "Heure de création de Cloudron",
|
||||
"cloudronVersion": "Version de Cloudron",
|
||||
"ubuntuVersion": "Version de Ubuntu"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Graphiques"
|
||||
},
|
||||
"locale": {
|
||||
"title": "Paramètres régionaux"
|
||||
},
|
||||
"title": "Système",
|
||||
"settings": {
|
||||
"title": "Paramètres"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -1284,7 +1584,8 @@
|
||||
"noUsername": {
|
||||
"title": "Impossible de configurer le compte",
|
||||
"description": "Le compte ne peut pas être configuré sans nom d'utilisateur."
|
||||
}
|
||||
},
|
||||
"welcome": "Bienvenue"
|
||||
},
|
||||
"login": {
|
||||
"resetPasswordAction": "Réinitialiser le mot de passe",
|
||||
@@ -1293,7 +1594,11 @@
|
||||
"username": "Nom d'utilisateur",
|
||||
"errorIncorrectCredentials": "Nom d'utilisateur ou mot de passe incorrect",
|
||||
"errorIncorrect2FAToken": "Le jeton 2FA n'est pas valide",
|
||||
"errorInternal": "Erreur interne, réessayer ultérieurement"
|
||||
"errorInternal": "Erreur interne, réessayer ultérieurement",
|
||||
"loginAction": "Se connecter",
|
||||
"usePasskeyAction": "Utiliser une clé d'accès",
|
||||
"errorPasskeyFailed": "Échec de la connexion avec la clé d'accès",
|
||||
"passkeyAction": "Se connecter avec la clé d'accès"
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"salutation": "Bonjour <%= user %>,",
|
||||
@@ -1313,7 +1618,8 @@
|
||||
"name": "Nom",
|
||||
"id": "ID du client",
|
||||
"secret": "Secret du client",
|
||||
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)"
|
||||
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)",
|
||||
"loginRedirectUriPlaceholder": "URL séparées par des virgules"
|
||||
},
|
||||
"description": "Cloudron peut agir en tant que fournisseur OpenID Connect pour les applications internes et les services externes.",
|
||||
"deleteClientDialog": {
|
||||
@@ -1329,6 +1635,73 @@
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "URL de découverte"
|
||||
},
|
||||
"clients": {
|
||||
"title": "Clients OpenID",
|
||||
"empty": "Aucun client OpenID"
|
||||
},
|
||||
"clientCredentials": {
|
||||
"title": "Identifiants du client"
|
||||
}
|
||||
},
|
||||
"userdirectory": {
|
||||
"settings": {
|
||||
"title": "Paramètres"
|
||||
}
|
||||
},
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Aucune application archivée"
|
||||
},
|
||||
"description": "Les applications archivées conservent la dernière sauvegarde effectuée au moment de leur archivage. Ces sauvegardes sont conservées de manière permanente et peuvent être restaurées."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
"label": "Site",
|
||||
"size": "Taille",
|
||||
"fileCount": "Fichiers"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites de secours",
|
||||
"emptyPlaceholder": "Pas de sites de secours",
|
||||
"lastRun": "Dernier lancement",
|
||||
"description": "Les emplacements de sauvegarde indiquent où sont stockées les sauvegardes du système et des applications. Les sauvegardes des applications peuvent être restaurées individuellement.",
|
||||
"noAutomaticUpdateBackupWarning": "Aucun site de sauvegarde n'est configuré pour stocker les sauvegardes des mises à jour automatiques. Activez l'option « Stocker ici les sauvegardes des mises à jour automatiques » sur au moins un site de sauvegarde pour permettre les mises à jour automatiques."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
"title": "Supprimer le site de secours"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerRegistries": {
|
||||
"server": "Adresse du serveur",
|
||||
"provider": "Fournisseur",
|
||||
"username": "Nom d'utilisateur",
|
||||
"title": "Registres Docker",
|
||||
"description": "Configurer l'accès aux registres Docker privés pour l'installation d'applications personnalisées.",
|
||||
"removeDialog": {
|
||||
"title": "Supprimer le registre Docker"
|
||||
},
|
||||
"email": "E-mail",
|
||||
"passwordToken": "Mot de passe/Jeton",
|
||||
"emptyPlaceholder": "Pas de registres Docker",
|
||||
"dialog": {
|
||||
"addTitle": "Ajouter un registre Docker",
|
||||
"editTitle": "Modifier le registre Docker"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord"
|
||||
},
|
||||
"server": {
|
||||
"title": "Serveur"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Les applications de la communauté ne sont pas vérifiées par Cloudron. N'installez que des applications provenant de développeurs de confiance. Le code tiers peut compromettre la sécurité de votre système.",
|
||||
"unstablewarning": "Cette application est signalée comme instable par son développeur."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"edit": "Edit"
|
||||
},
|
||||
"table": {
|
||||
"version": "Versi"
|
||||
"version": "Versi",
|
||||
"created": "Dibuat"
|
||||
},
|
||||
"logout": "Keluar",
|
||||
"action": {
|
||||
@@ -42,7 +43,10 @@
|
||||
"configure": "Konfigurasi",
|
||||
"restart": "Mulai ulang",
|
||||
"reset": "Atur Ulang",
|
||||
"logs": "Log"
|
||||
"logs": "Log",
|
||||
"loadMore": "Muat lebih banyak",
|
||||
"setup": "Siapkan",
|
||||
"disable": "Nonaktifkan"
|
||||
},
|
||||
"searchPlaceholder": "Cari",
|
||||
"actions": "Tindakan",
|
||||
@@ -87,7 +91,7 @@
|
||||
"userManagementAllUsers": "Izinkan semua pengguna di Cloudron ini",
|
||||
"userManagementSelectUsers": "Hanya izinkan pengguna dan grup berikut ini",
|
||||
"errorUserManagementSelectAtLeastOne": "Pilih setidaknya satu pengguna atau grup",
|
||||
"configuredForCloudronEmail": "Aplikasi ini telah dikonfigurasi sebelumnya untuk digunakan dengan <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail Cloudron </a>.",
|
||||
"configuredForCloudronEmail": "Aplikasi ini telah dikonfigurasi sebelumnya untuk digunakan dengan <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail Cloudron</a>.",
|
||||
"cloudflarePortWarning": "Proksi Cloudflare harus dinonaktifkan agar domain aplikasi dapat mengakses port ini",
|
||||
"portReadOnly": "hanya baca",
|
||||
"ephemeralPortWarning": "Menggunakan port dinamis dapat menyebabkan konflik yang tidak terduga."
|
||||
@@ -103,7 +107,10 @@
|
||||
},
|
||||
"unstable": "Tidak stabil",
|
||||
"title": "Toko Aplikasi",
|
||||
"searchPlaceholder": "Cari alternatif seperti GitHub, Dropbox, Slack, Trello, …"
|
||||
"searchPlaceholder": "Cari alternatif seperti GitHub, Dropbox, Slack, Trello, …",
|
||||
"action": {
|
||||
"addCustomApp": "Tambahkan aplikasi kustom"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"users": {
|
||||
@@ -275,11 +282,12 @@
|
||||
"app": "Aplikasi",
|
||||
"title": "Kata sandi Aplikasi",
|
||||
"noPasswordsPlaceholder": "Tidak ada kata sandi aplikasi",
|
||||
"description": "Kata sandi aplikasi adalah langkah keamanan untuk melindungi akun pengguna Cloudron Anda. Jika Anda perlu mengakses aplikasi Cloudron dari aplikasi seluler atau klien yang tidak tepercaya, Anda dapat masuk dengan nama pengguna Anda dan kata sandi alternatif yang dihasilkan di sini."
|
||||
"description": "Kata sandi aplikasi adalah langkah keamanan untuk melindungi akun pengguna Cloudron Anda. Jika Anda perlu mengakses aplikasi Cloudron dari aplikasi seluler atau klien yang tidak tepercaya, Anda dapat masuk dengan nama pengguna Anda dan kata sandi alternatif yang dihasilkan di sini.",
|
||||
"expires": "Kadaluarsa"
|
||||
},
|
||||
"apiTokens": {
|
||||
"name": "Nama",
|
||||
"lastUsed": "Terakhir Digunakan",
|
||||
"lastUsed": "Terakhir digunakan",
|
||||
"title": "Token API",
|
||||
"scope": "Cakupan",
|
||||
"description": "Gunakan token akses pribadi ini untuk melakukan otentikasi dengan <a target=\"_blank\" href=\"{{ apiDocsLink }}\">API Cloudron</a>.",
|
||||
@@ -295,7 +303,11 @@
|
||||
"token": "Token",
|
||||
"enable": "Aktifkan",
|
||||
"mandatorySetup": "2FA diperlukan untuk mengakses dasbor. Silakan selesaikan pengaturan untuk melanjutkan.",
|
||||
"authenticatorAppDescription": "Gunakan Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) atau aplikasi TOTP serupa untuk memindai kode rahasia."
|
||||
"authenticatorAppDescription": "Gunakan Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) atau aplikasi TOTP serupa untuk memindai kode rahasia.",
|
||||
"passkeyOption": "Passkey",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Siapkan passkey",
|
||||
"passkeyDescription": "Browser akan meminta Anda untuk membuat passkey menggunakan biometrik perangkat Anda atau pengelola kata sandi."
|
||||
},
|
||||
"language": "Bahasa",
|
||||
"loginTokens": {
|
||||
@@ -313,10 +325,9 @@
|
||||
"title": "Tambahkan Kata Sandi Aplikasi",
|
||||
"name": "Nama kata sandi",
|
||||
"description": "Gunakan kata sandi berikut untuk mengautentikasi terhadap aplikasi:",
|
||||
"copyNow": "Silakan salin kata sandi sekarang. Kata sandi ini tidak akan ditampilkan lagi untuk alasan keamanan."
|
||||
"copyNow": "Silakan salin kata sandi sekarang. Kata sandi ini tidak akan ditampilkan lagi untuk alasan keamanan.",
|
||||
"expiresAt": "Tanggal kedaluwarsa"
|
||||
},
|
||||
"disable2FAAction": "Nonaktifkan 2FA",
|
||||
"enable2FAAction": "Aktifkan 2FA",
|
||||
"title": "Profil",
|
||||
"primaryEmail": "E-mail utama",
|
||||
"passwordRecoveryEmail": "E-mail pemulihan kata sandi",
|
||||
@@ -349,6 +360,26 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Hapus Kata sandi Aplikasi",
|
||||
"description": "Hapus kata sandi aplikasi \"{{ name }}\"?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Autentikasi dua faktor",
|
||||
"totpEnabled": "Diaktifkan",
|
||||
"passkeyEnabled": "Diaktifkan",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Belum diatur",
|
||||
"enablePasskey": {
|
||||
"title": "Aktifkan passkey"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Aktifkan TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Nonaktifkan TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Nonaktifkan Passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -362,15 +393,19 @@
|
||||
"tooltipDownloadBackupConfig": "Unduh konfigurasi",
|
||||
"cleanupBackups": "Bersihkan cadangan",
|
||||
"tooltipPreservedBackup": "Cadangan ini akan dipertahankan",
|
||||
"title": "Pencadangan Sistem"
|
||||
"title": "Pencadangan Sistem",
|
||||
"description": "Cadangan sistem berisi konfigurasi Cloudron dan metadata instalasi aplikasi. Cadangan ini dapat digunakan untuk <a href=\"{{restoreLink}}\" target=\"_blank\">memulihkan</a> atau <a href=\"{{migrateLink}}\" target=\"_blank\">memigrasikan</a> seluruh instalasi Cloudron ke server lain."
|
||||
},
|
||||
"backupDetails": {
|
||||
"duration": "Durasi",
|
||||
"version": "Versi",
|
||||
"duration": "Durasi cadangan",
|
||||
"version": "Versi paket",
|
||||
"title": "Detail Cadangan",
|
||||
"id": "Id",
|
||||
"date": "Tanggal",
|
||||
"size": "Ukuran"
|
||||
"id": "ID Cadangan",
|
||||
"date": "Dibuat",
|
||||
"size": "Ukuran",
|
||||
"lastIntegrityCheck": "Pemeriksaan integritas terakhir",
|
||||
"integrityNever": "tidak pernah",
|
||||
"integrityInProgress": "Sedang diproses"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"hours": "Jam",
|
||||
@@ -497,7 +532,9 @@
|
||||
"title": "Konfigurasi Konten Cadangan"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Enkripsi berkas dan nama berkas digunakan",
|
||||
"useFileEncryption": "Enkripsi berkas digunakan"
|
||||
"useFileEncryption": "Enkripsi berkas digunakan",
|
||||
"checkIntegrity": "Periksa integritas",
|
||||
"stopIntegrity": "Hentikan pemeriksaan integritas"
|
||||
},
|
||||
"branding": {
|
||||
"logo": "Logo",
|
||||
@@ -704,17 +741,16 @@
|
||||
"updateAvailableAction": "Pembaruan tersedia",
|
||||
"stopUpdateAction": "Hentikan pembaruan",
|
||||
"disabled": "Dinonaktifkan",
|
||||
"schedule": "Jadwal pembaruan",
|
||||
"description": "Pembaruan platform dan aplikasi diterapkan sesuai jadwal yang telah dikonfigurasi, menggunakan <a href=\"/#/system-settings\">Zona waktu sistem</a>.",
|
||||
"onLatest": "terbaru"
|
||||
"description": "Pembaruan diterapkan sesuai jadwal yang telah dikonfigurasi, menggunakan <a href=\"/#/system-settings\">Zona waktu sistem</a>.",
|
||||
"onLatest": "terbaru",
|
||||
"config": "Pembaruan otomatis",
|
||||
"appsOnly": "Hanya aplikasi",
|
||||
"platformAndApps": "Platform & aplikasi"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Konfigurasi Jadwal Pembaruan Otomatis",
|
||||
"disableCheckbox": "Nonaktifkan pembaruan otomatis",
|
||||
"enableCheckbox": "Aktifkan pembaruan otomatis",
|
||||
"selectOne": "Pilih setidaknya satu hari dan satu waktu",
|
||||
"days": "Hari",
|
||||
"hours": "Jam",
|
||||
"description": "Atur hari dan waktu untuk pembaruan otomatis platform dan aplikasi. Pastikan jadwal ini tidak tumpang tindih dengan jadwal pencadangan."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -734,6 +770,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Penyedia registri Docker",
|
||||
"providerOther": "Lainnya"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Konfigurasi Pembaruan Otomatis",
|
||||
"policy": "Kebijakan",
|
||||
"policyDescription": "Pilih apa yang diperbarui secara otomatis",
|
||||
"days": "Hari",
|
||||
"hours": "Jam",
|
||||
"schedule": "Jadwal"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -791,7 +835,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Dasbor Domain",
|
||||
"description": "Ubah dashboard ke subdomain 'my' pada domain yang dipilih",
|
||||
"changeAction": "Ubah domain"
|
||||
"changeAction": "Ubah domain",
|
||||
"confirmMessage": "Ini akan membatalkan semua passkey untuk pengguna.",
|
||||
"confirmTitle": "Apakah Anda benar-benar ingin mengubah domain dasbor?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"addTitle": "Tambahkan Domain",
|
||||
@@ -849,7 +895,9 @@
|
||||
"inwxUsername": "Nama pengguna INWX",
|
||||
"inwxPassword": "Kata sandi INWX",
|
||||
"customNameservers": "Domain menggunakan nameserver kustom (vanity)",
|
||||
"zoneNamePlaceholder": "Opsional. Jika tidak disediakan, akan menggunakan domain utama sebagai bawaan."
|
||||
"zoneNamePlaceholder": "Opsional. Jika tidak disediakan, akan menggunakan domain utama sebagai bawaan.",
|
||||
"carddavLocation": "Lokasi server CardDAV",
|
||||
"caldavLocation": "Lokasi server CalDAV"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Hapus Domain",
|
||||
@@ -888,11 +936,11 @@
|
||||
"reallyDelete": "Apakah Anda yakin ingin menghapus?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "Nama Folder Baru",
|
||||
"title": "Folder Baru",
|
||||
"create": "Buat"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "Nama berkas Baru",
|
||||
"title": "Nama berkas baru",
|
||||
"create": "Buat"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -910,16 +958,17 @@
|
||||
"restartApp": "Mulai ulang Aplikasi",
|
||||
"uploadFolder": "Unggah folder",
|
||||
"openTerminal": "Buka terminal",
|
||||
"openLogs": "Buka log"
|
||||
"openLogs": "Buka log",
|
||||
"refresh": "Segarkan"
|
||||
},
|
||||
"extractionInProgress": "Ekstraksi sedang berlangsung",
|
||||
"pasteInProgress": "Penempelan sedang berlangsung",
|
||||
"deleteInProgress": "Penghapusan sedang berlangsung",
|
||||
"chownDialog": {
|
||||
"title": "Ubah kepemilikan",
|
||||
"title": "Ubah pemilik",
|
||||
"newOwner": "Pemilik baru",
|
||||
"change": "Ubah Pemilik",
|
||||
"recursiveCheckbox": "Ubah kepemilikan secara rekursif"
|
||||
"change": "Ubah pemilik",
|
||||
"recursiveCheckbox": "Ubah pemilik secara rekursif"
|
||||
},
|
||||
"uploadingDialog": {
|
||||
"title": "Mengunggah berkas ({{ countDone }}/{{ count }})",
|
||||
@@ -1159,11 +1208,11 @@
|
||||
"aliases": "Alias",
|
||||
"addAliasAction": "Tambahkan alias",
|
||||
"noAliases": "Tidak ada domain alias",
|
||||
"dnsoverwrite": "Beberapa catatan DNS sudah ada. Setuju untuk menimpa."
|
||||
"overwriteDns": "Menimpa catatan DNS yang ada pada {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
"description": "Konfigurasikan siapa yang dapat masuk dan menggunakan aplikasi.",
|
||||
"description": "Konfigurasikan siapa yang dapat masuk dan menggunakan aplikasi",
|
||||
"descriptionSftp": "Pengaturan ini juga mengontrol akses SFTP.",
|
||||
"dashboardVisibility": "Visibilitas Dasbor",
|
||||
"visibleForAllUsers": "Terlihat oleh semua pengguna di Cloudron ini",
|
||||
@@ -1177,7 +1226,7 @@
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operator",
|
||||
"description": "Para operator dapat mengonfigurasi dan memelihara aplikasi ini."
|
||||
"description": "Konfigurasikan siapa yang dapat memelihara aplikasi"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Konfigurasikan siapa yang dapat melihat aplikasi ini di dasbor."
|
||||
@@ -1282,7 +1331,7 @@
|
||||
"cron": {
|
||||
"title": "Crontab",
|
||||
"saveAction": "Simpan",
|
||||
"addCommonPattern": "Tambahkan pola umum",
|
||||
"addCommonPattern": "Masukkan pola umum",
|
||||
"commonPattern": {
|
||||
"everyMinute": "Setiap Menit",
|
||||
"everyHour": "Setiap Jam",
|
||||
@@ -1334,13 +1383,29 @@
|
||||
"accessControlTabTitle": "Kontrol Akses",
|
||||
"security": {
|
||||
"csp": {
|
||||
"description": "Timpa semua header CSP yang ditentukan oleh aplikasi.",
|
||||
"description": "Timpa semua header CSP yang ditentukan oleh aplikasi",
|
||||
"title": "Kebijakan Keamanan Konten",
|
||||
"saveAction": "Simpan"
|
||||
"saveAction": "Simpan",
|
||||
"insertCommonCsp": "Masukkan CSP umum",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Izinkan penyematan",
|
||||
"sameOriginEmbedding": "Izinkan penyematan (hanya subdomain)",
|
||||
"allowCdnAssets": "Izinkan aset CDN",
|
||||
"reportOnly": "Laporkan pelanggaran CSP",
|
||||
"strictBaseline": "Baseline yang ketat"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"description": "Secara bawaan, bot dapat mengindeks aplikasi ini."
|
||||
"description": "Secara bawaan, bot dapat mengindeks aplikasi ini",
|
||||
"commonPattern": {
|
||||
"allowAll": "Izinkan semua (bawaan)",
|
||||
"disallowAll": "Larang semua",
|
||||
"disallowCommonBots": "Larang bot umum",
|
||||
"disallowAdminPaths": "Larang akses jalur admin",
|
||||
"disallowApiPaths": "Larang jalur API"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Masukkan robots.txt umum"
|
||||
},
|
||||
"hstsPreload": "Aktifkan HSTS Preload (termasuk subdomain)"
|
||||
},
|
||||
@@ -1351,20 +1416,21 @@
|
||||
"packageVersion": "Versi paket",
|
||||
"lastUpdated": "Terakhir diperbarui",
|
||||
"customAppUpdateInfo": "Pembaruan otomatis tidak tersedia untuk aplikasi khusus.",
|
||||
"installedAt": "Terpasang"
|
||||
"installedAt": "Terpasang",
|
||||
"packager": "Pengemas"
|
||||
},
|
||||
"auto": {
|
||||
"description": "Pembaruan aplikasi diterapkan secara berkala berdasarkan <a href=\"/#/system-update\">jadwal pembaruan</a>",
|
||||
"title": "Pembaruan otomatis"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron secara otomatis memeriksa pembaruan di App Store. Anda juga dapat memeriksanya secara manual."
|
||||
"description": "Cloudron secara otomatis memeriksa pembaruan. Anda juga dapat memeriksanya secara manual."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"backups": {
|
||||
"title": "Cadangan",
|
||||
"description": "Buat snapshot lengkap dari aplikasi tersebut.",
|
||||
"description": "Buat snapshot lengkap dari aplikasi tersebut",
|
||||
"downloadConfigTooltip": "Unduh konfigurasi",
|
||||
"cloneTooltip": "Klon",
|
||||
"restoreTooltip": "Pulihkan",
|
||||
@@ -1375,7 +1441,7 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Impor",
|
||||
"description": "Impor aplikasi dari cadangan eksternal."
|
||||
"description": "Impor aplikasi dari cadangan eksternal"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Cadangan otomatis",
|
||||
@@ -1401,11 +1467,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "Mulai",
|
||||
"stopAction": "Berhenti",
|
||||
"description": "Aplikasi dapat dihentikan untuk menghemat sumber daya server daripada menghapusnya. Cadangan aplikasi di masa mendatang tidak akan mencakup perubahan pada aplikasi antara sekarang dan cadangan aplikasi terbaru. Oleh karena itu, disarankan untuk memicu cadangan sebelum menghentikan aplikasi."
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Hapus instalasi",
|
||||
"description": "Hapus instalasi aplikasi dan hapus datanya. Cadangan dibersihkan sesuai dengan kebijakan pencadangan.",
|
||||
@@ -1450,7 +1511,17 @@
|
||||
"title": "Arsipkan Aplikasi",
|
||||
"description": "Hapus aplikasi {{ app }} dan pindahkan cadangan terbarunya (dibuat pada {{ date }}) ke arsip aplikasi?"
|
||||
},
|
||||
"updateAvailableTooltip": "Pembaruan tersedia"
|
||||
"updateAvailableTooltip": "Pembaruan tersedia",
|
||||
"start": {
|
||||
"title": "Mulai",
|
||||
"description": "Mulai aplikasi untuk membuatnya tersedia kembali.",
|
||||
"action": "Mulai"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Berhenti",
|
||||
"title": "Berhenti",
|
||||
"description": "Hentikan aplikasi untuk menghemat sumber daya. Cadangkan sebelum menghentikan untuk mempertahankan perubahan terakhir."
|
||||
}
|
||||
},
|
||||
"setupAccount": {
|
||||
"errorPassword": "Kata sandi harus setidaknya 8 karakter",
|
||||
@@ -1573,7 +1644,8 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Tidak ada aplikasi yang diarsipkan"
|
||||
}
|
||||
},
|
||||
"description": "Aplikasi yang diarsipkan menyimpan cadangan terbaru saat aplikasi tersebut diarsipkan. Cadangan ini disimpan secara permanen dan dapat dipulihkan."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
@@ -1584,7 +1656,9 @@
|
||||
"sites": {
|
||||
"title": "Situs Cadangan",
|
||||
"emptyPlaceholder": "Tidak ada situs cadangan",
|
||||
"lastRun": "Terakhir dijalankan"
|
||||
"lastRun": "Terakhir dijalankan",
|
||||
"description": "Lokasi cadangan menunjukkan di mana cadangan sistem dan cadangan aplikasi disimpan. Cadangan aplikasi dapat dipulihkan secara terpisah.",
|
||||
"noAutomaticUpdateBackupWarning": "Tidak ada situs cadangan yang dikonfigurasi untuk menyimpan cadangan pembaruan otomatis. Aktifkan \"Simpan cadangan pembaruan otomatis di sini\" pada setidaknya satu situs cadangan untuk memungkinkan pembaruan otomatis."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1616,12 +1690,19 @@
|
||||
"appDown": "Aplikasi sedang tidak berfungsi",
|
||||
"rebootRequired": "Diperlukan menyalakan ulang server",
|
||||
"cloudronUpdateFailed": "Pembaruan Cloudron gagal",
|
||||
"diskSpace": "Ruang disk hampir penuh"
|
||||
"diskSpace": "Ruang disk hampir penuh",
|
||||
"appAutoUpdateFailed": "Pembaruan otomatis aplikasi gagal",
|
||||
"manualUpdateRequired": "Platform atau aplikasi memerlukan pembaruan manual"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "E-mail akan dikirimkan ke e-mail utama Anda untuk acara-acara yang dipilih."
|
||||
},
|
||||
"allCaughtUp": "Semua sudah ditangani"
|
||||
"allCaughtUp": "Semua sudah ditangani",
|
||||
"title": "Notifikasi",
|
||||
"showAll": "Semua",
|
||||
"showUnread": "Belum dibaca",
|
||||
"markUnread": "Tandai sebagai belum dibaca",
|
||||
"markRead": "Tandai sebagai sudah dibaca"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Log",
|
||||
@@ -1636,7 +1717,10 @@
|
||||
"resetPasswordAction": "Atur ulang kata sandi",
|
||||
"errorIncorrect2FAToken": "Token 2FA tidak valid",
|
||||
"errorInternal": "Terjadi kesalahan internal, coba lagi nanti",
|
||||
"loginAction": "Masuk"
|
||||
"loginAction": "Masuk",
|
||||
"usePasskeyAction": "Gunakan passkey",
|
||||
"errorPasskeyFailed": "Gagal masuk dengan passkey",
|
||||
"passkeyAction": "Masuk dengan passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Pengaturan ulang kata sandi",
|
||||
@@ -1658,5 +1742,9 @@
|
||||
"title": "Kata sandi telah diubah",
|
||||
"openDashboardAction": "Buka dasbor"
|
||||
}
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Aplikasi komunitas tidak ditinjau oleh Cloudron. Hanya instal aplikasi dari pengembang tepercaya. Kode pihak ketiga dapat membahayakan sistem Anda.",
|
||||
"unstablewarning": "Aplikasi ini ditandai sebagai tidak stabil oleh pengembangnya."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,6 @@
|
||||
"uninstallAction": "Disinstalla",
|
||||
"description": "Questo disinstallerà immediatamente l'app e rimuoverà tutti i suoi dati. Il sito sarà inaccessibile.",
|
||||
"title": "Disinstalla"
|
||||
},
|
||||
"startStop": {
|
||||
"stopAction": "Ferma App",
|
||||
"startAction": "Avvia App",
|
||||
"description": "Le app possono essere interrotte per risparmiare le risorse del server invece di disinstallarle. I backup futuri delle app non includeranno alcuna modifica dell'app da adesso fino al backup dell'app più recente. Per questo motivo, si consiglia di fare un backup prima di arrestare l'app."
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
@@ -565,8 +560,6 @@
|
||||
"title": "Backup"
|
||||
},
|
||||
"profile": {
|
||||
"enable2FAAction": "Abilita 2FA",
|
||||
"disable2FAAction": "Disabilita 2FA",
|
||||
"changePasswordAction": "Cambia Password",
|
||||
"createApiToken": {
|
||||
"copyNow": "Copia il token API ora. Non verrà mostrato di nuovo per motivi di sicurezza.",
|
||||
@@ -787,12 +780,9 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Seleziona i giorni e gli orari durante i quali Cloudron applicherà gli aggiornamenti automatici della piattaforma e dell'app. Fai attenzione a non sovrapporre questa pianificazione alla <a href=\"/#/backups\">pianificazione dei backup</a>.",
|
||||
"hours": "Ore",
|
||||
"days": "Giorni",
|
||||
"selectOne": "Seleziona almeno un giorno e un'ora",
|
||||
"enableCheckbox": "Abilita Aggiornamenti Automatici",
|
||||
"disableCheckbox": "Disabilita Aggiornamenti Automatici",
|
||||
"title": "Configura pianificazione aggiornamenti automatici"
|
||||
"disableCheckbox": "Disabilita Aggiornamenti Automatici"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Ferma Aggiornamento",
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
"next": "Volgende",
|
||||
"configure": "Configureer",
|
||||
"restart": "Herstart",
|
||||
"reset": "Reset"
|
||||
"reset": "Reset",
|
||||
"loadMore": "Laad meer",
|
||||
"setup": "Instellen",
|
||||
"disable": "Uitschakelen"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Herstart Server",
|
||||
@@ -104,6 +107,9 @@
|
||||
"appNotFoundDialog": {
|
||||
"title": "App niet gevonden",
|
||||
"description": "De app <b>{{ appId }}</b> met versie <b>{{ version }}</b> bestaat niet."
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Aangepaste app toevoegen"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
@@ -287,14 +293,19 @@
|
||||
"enable": "Inschakelen",
|
||||
"title": "Schakel Twee-Factor (2FA) authenticatie in",
|
||||
"authenticatorAppDescription": "Gebruik Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) of vergelijkbare Twee-Factor (2FA) authenticatie app om de QR-code te scannen.",
|
||||
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan."
|
||||
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan.",
|
||||
"passkeyOption": "Passkey",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Instellen passkey",
|
||||
"passkeyDescription": "De browser zal je vragen een passkey aan te maken met de biometrie van je apparaat of via een wachtwoordbeheerder."
|
||||
},
|
||||
"appPasswords": {
|
||||
"app": "App",
|
||||
"name": "Naam",
|
||||
"noPasswordsPlaceholder": "Geen app-wachtwoorden",
|
||||
"title": "App wachtwoorden",
|
||||
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken."
|
||||
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken.",
|
||||
"expires": "Verloopt"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
@@ -327,7 +338,8 @@
|
||||
"app": "App",
|
||||
"description": "Het volgende wachtwoord is gegenereerd voor de app:",
|
||||
"name": "Beschrijving van het wachtwoord",
|
||||
"copyNow": "Let op: kopieer het wachtwoord nu, vanwege veiligheidsredenen wordt het nooit meer getoond."
|
||||
"copyNow": "Let op: kopieer het wachtwoord nu, vanwege veiligheidsredenen wordt het nooit meer getoond.",
|
||||
"expiresAt": "Vervaldatum"
|
||||
},
|
||||
"createApiToken": {
|
||||
"title": "API Token aanmaken",
|
||||
@@ -338,8 +350,6 @@
|
||||
"allowedIpRanges": "Toegestane IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Verander Wachtwoord",
|
||||
"disable2FAAction": "Twee-Factor (2FA) authenticatie uitschakelen",
|
||||
"enable2FAAction": "Twee-Factor (2FA) authenticatie inschakelen",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail gestuurd naar {{ email }}"
|
||||
},
|
||||
@@ -350,6 +360,26 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Verwijder app-wachtwoord",
|
||||
"description": "Verwijder App-wachtwoord \"{{ name }}\"?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Twee-Factor (2FA) authenticatie",
|
||||
"totpEnabled": "Ingeschakeld",
|
||||
"passkeyEnabled": "Ingeschakeld",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Niet ingesteld",
|
||||
"enablePasskey": {
|
||||
"title": "Passkey activeren"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "TOTP activeren"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "TOTP Uitschakelen"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Passkey uitschakelen"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -372,7 +402,8 @@
|
||||
"backupNow": "Backup maken",
|
||||
"appCount": "{{ appCount }} App(s)",
|
||||
"tooltipDownloadBackupConfig": "Download configuratie",
|
||||
"tooltipPreservedBackup": "Deze backup blijft behouden"
|
||||
"tooltipPreservedBackup": "Deze backup blijft behouden",
|
||||
"description": "Systeembackups bevatten Cloudron-configuratie en metadata van app-installaties. Ze kunnen worden gebruikt om te <a href=\"{{restoreLink}}\" target=\"_blank\">herstellen</a> of te <a href=\"{{migrateLink}}\" target=\"_blank\">migreren</a> van de volledige Cloudron-installatie naar een andere server."
|
||||
},
|
||||
"backupDetails": {
|
||||
"title": "Backup Details",
|
||||
@@ -380,7 +411,10 @@
|
||||
"date": "Aangemaakt",
|
||||
"version": "Package versie",
|
||||
"size": "Grootte",
|
||||
"duration": "Backup duur"
|
||||
"duration": "Backup duur",
|
||||
"lastIntegrityCheck": "Laatste integriteitscontrole",
|
||||
"integrityNever": "nooit",
|
||||
"integrityInProgress": "In uitvoering"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "Configureer Backup Planning & Bewaartermijn",
|
||||
@@ -498,7 +532,9 @@
|
||||
"title": "Configureer Backup Inhoud"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Bestand en bestandsnaam encryptie gebruikt",
|
||||
"useFileEncryption": "Bestand encryptie gebruikt"
|
||||
"useFileEncryption": "Bestand encryptie gebruikt",
|
||||
"checkIntegrity": "Controleer integriteit",
|
||||
"stopIntegrity": "Stop integriteitscontrole"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Huisstijl",
|
||||
@@ -652,7 +688,9 @@
|
||||
"inwxUsername": "INWX gebruikersnaam",
|
||||
"inwxPassword": "INWX wachtwoord",
|
||||
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers",
|
||||
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt."
|
||||
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt.",
|
||||
"carddavLocation": "CardDAV-server locatie",
|
||||
"caldavLocation": "CalDAV server locatie"
|
||||
},
|
||||
"title": "Domeinen",
|
||||
"domain": "Domein",
|
||||
@@ -665,7 +703,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"changeAction": "Domein aanpassen",
|
||||
"title": "Dashboard Domein",
|
||||
"description": "Verander het Dashboard naar het “my” subdomein van het geselecteerde domein"
|
||||
"description": "Verander het Dashboard naar het “my” subdomein van het geselecteerde domein",
|
||||
"confirmMessage": "Dit zal alle passkeys voor gebruikers ongeldig maken.",
|
||||
"confirmTitle": "Wil je echt het dashboard-domein wijzigen?"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Verwijder domein",
|
||||
@@ -746,7 +786,7 @@
|
||||
"noAliases": "Geen alias-domeinen",
|
||||
"addAliasAction": "Alias toevoegen",
|
||||
"aliases": "Aliassen",
|
||||
"dnsoverwrite": "Sommige DNS records bestaan al. Weet je zeker dat ze overschreven moeten worden?"
|
||||
"overwriteDns": "Overschrijf bestaande DNS records van {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
@@ -854,14 +894,15 @@
|
||||
"packageVersion": "Pakketversie",
|
||||
"lastUpdated": "Laatst geüpdatet",
|
||||
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
|
||||
"installedAt": "Geïnstalleerd"
|
||||
"installedAt": "Geïnstalleerd",
|
||||
"packager": "Pakketmaker"
|
||||
},
|
||||
"auto": {
|
||||
"description": "App updates worden uitgevoerd op basis van de <a href=\"/#/system-update\">update planning</a>.",
|
||||
"title": "Automatische updates"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron controleert automatisch de App Store op updates. Je kunt ook handmatig controleren."
|
||||
"description": "Cloudron controleert automatisch op app-updates. Je kunt dit ook handmatig controleren."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -904,11 +945,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "Start",
|
||||
"stopAction": "Stop",
|
||||
"description": "Apps kunnen ook gestopt worden in plaats van de-installeren om server capaciteit vrij te maken. Toekomstige app backups bevatten geen wijzigingen tussen nu en de laatste app backup. Start daarom handmatig een backup alvorens de app te stoppen."
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "De-installeer",
|
||||
"uninstallAction": "De-installeer",
|
||||
@@ -1022,6 +1058,16 @@
|
||||
"forumAction": "Forum",
|
||||
"appLink": {
|
||||
"title": "Externe Link"
|
||||
},
|
||||
"start": {
|
||||
"title": "Start",
|
||||
"description": "Start de app om deze weer beschikbaar te maken.",
|
||||
"action": "Start"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Stop",
|
||||
"title": "Stop",
|
||||
"description": "Stop de app om bronnen te besparen. Maak vóór het stoppen een back-up om recente wijzigingen te behouden."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -1112,18 +1158,17 @@
|
||||
"checkForUpdatesAction": "Controleer op updates",
|
||||
"updateAvailableAction": "Update beschikbaar",
|
||||
"stopUpdateAction": "Stop update",
|
||||
"description": "Platform en app updates worden toegepast met de geconfigureerde planning met deze <a href=\"/#/system-locale\">Systeem tijdzone</a>.",
|
||||
"description": "Updates worden toegepast volgens het geconfigureerde schema, met behulp van de <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"schedule": "Update planning",
|
||||
"onLatest": "Laatste"
|
||||
"onLatest": "Laatste",
|
||||
"config": "Automatische updates",
|
||||
"appsOnly": "Alleen Apps",
|
||||
"platformAndApps": "Platform & Apps"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"disableCheckbox": "Automatische updates uitschakelen",
|
||||
"enableCheckbox": "Automatische updates inschakelen",
|
||||
"selectOne": "Selecteer minstens één dag en tijd",
|
||||
"days": "Dagen",
|
||||
"hours": "Uren",
|
||||
"title": "Automatische Update Planning configureren",
|
||||
"description": "Stel de dagen en uren in voor automatische updates van het platform en apps. Zorg ervoor dat dit schema niet overlapt met de back-upschema's."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1144,6 +1189,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Docker registry aanbieder",
|
||||
"providerOther": "Anders"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Automatische updates configureren",
|
||||
"policy": "Beleid",
|
||||
"policyDescription": "Kies wat er automatisch wordt bijgewerkt",
|
||||
"days": "Dagen",
|
||||
"hours": "Uren",
|
||||
"schedule": "Planning"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1201,12 +1254,19 @@
|
||||
"appDown": "App werkt niet",
|
||||
"rebootRequired": "Server herstart noodzakelijk",
|
||||
"cloudronUpdateFailed": "Cloudron update mislukt",
|
||||
"diskSpace": "Weinig diskruimte"
|
||||
"diskSpace": "Weinig diskruimte",
|
||||
"appAutoUpdateFailed": "Automatische update van de app is mislukt",
|
||||
"manualUpdateRequired": "Platform of app moet handmatig geüpdatet worden"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "Een e-mail wordt verstuurd voor de geselecteerde gebeurtenissen naar je primaire e-mail."
|
||||
},
|
||||
"allCaughtUp": "Alles bijgewerkt"
|
||||
"allCaughtUp": "Alles bijgewerkt",
|
||||
"title": "Notificaties",
|
||||
"showAll": "Alles",
|
||||
"showUnread": "Ongelezen",
|
||||
"markUnread": "Markeer als ongelezen",
|
||||
"markRead": "Markeer als gelezen"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbestanden",
|
||||
@@ -1230,11 +1290,11 @@
|
||||
"reallyDelete": "Wil je het echt verwijderen?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "Nieuwe mapnaam",
|
||||
"title": "Nieuwe map",
|
||||
"create": "Aanmaken"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "Nieuw bestandsnaam",
|
||||
"title": "Nieuwe bestandsnaam",
|
||||
"create": "Aanmaken"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -1252,15 +1312,16 @@
|
||||
"newFolder": "Nieuwe map",
|
||||
"uploadFolder": "Upload map",
|
||||
"openTerminal": "Open terminal",
|
||||
"openLogs": "Open logbestanden"
|
||||
"openLogs": "Open logbestanden",
|
||||
"refresh": "Ververs"
|
||||
},
|
||||
"extractionInProgress": "Bezig met uitpakken",
|
||||
"pasteInProgress": "Bezig met plakken",
|
||||
"deleteInProgress": "Bezig met verwijderen",
|
||||
"chownDialog": {
|
||||
"title": "Eigenaarschap veranderen",
|
||||
"title": "Eigenaar veranderen",
|
||||
"newOwner": "Nieuwe eigenaar",
|
||||
"change": "Eigenaar aanpassen",
|
||||
"change": "Eigenaar veranderen",
|
||||
"recursiveCheckbox": "Eigenaar recursief aanpassen"
|
||||
},
|
||||
"uploadingDialog": {
|
||||
@@ -1481,7 +1542,10 @@
|
||||
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
|
||||
"errorIncorrect2FAToken": "2FA token is niet geldig",
|
||||
"errorInternal": "Interne fout, probeer later opnieuw",
|
||||
"loginAction": "Inloggen"
|
||||
"loginAction": "Inloggen",
|
||||
"usePasskeyAction": "Gebruik een passkey",
|
||||
"errorPasskeyFailed": "Inloggen met passkey mislukt",
|
||||
"passkeyAction": "Inloggen met een passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Wachtwoord herstellen",
|
||||
@@ -1625,7 +1689,8 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Geen gearchiveerde apps"
|
||||
}
|
||||
},
|
||||
"description": "Gearchiveerde apps bewaren de laatst gemaakte backup op het moment van archiveren. Deze backups worden permanent bewaard en kunnen worden hersteld."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
@@ -1636,7 +1701,9 @@
|
||||
"sites": {
|
||||
"title": "Backup Locaties",
|
||||
"emptyPlaceholder": "Geen backup locaties",
|
||||
"lastRun": "Laatste uitvoering"
|
||||
"lastRun": "Laatste uitvoering",
|
||||
"description": "Backuplocaties geven aan waar systeem- en app-backups worden opgeslagen. App-backups kunnen afzonderlijk worden hersteld.",
|
||||
"noAutomaticUpdateBackupWarning": "Er is geen back-uplocatie geconfigureerd om back-ups op te slaan voor automatische updates. Schakel \"Hier automatische back-ups opslaan\" in op minstens één back-uplocatie om automatische updates mogelijk te maken."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1675,5 +1742,9 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "Server"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Community-apps worden niet door Cloudron beoordeeld. Installeer alleen apps van betrouwbare ontwikkelaars. Code van derden kan uw systeem in gevaar brengen.",
|
||||
"unstablewarning": "Deze app is door de ontwikkelaar gemarkeerd als onstabiel."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +180,6 @@
|
||||
"description": "As palavras-passe da aplicação são uma medida de segurança para proteger a sua conta de utilizador Cloudron. Se precisar de aceder a uma aplicação Cloudron a partir de uma aplicação móvel ou cliente não fidedigno, pode iniciar a sessão com o seu nome de utilizador e a palavra-passe alternativa gerada aqui."
|
||||
},
|
||||
"changePasswordAction": "Alterar palavra-passe",
|
||||
"disable2FAAction": "Desativar 2FA",
|
||||
"enable2FAAction": "Ativar 2FA",
|
||||
"removeAppPassword": {
|
||||
"title": "Remover Palavra-passe da Aplicação",
|
||||
"description": "Remover a palavra-passe da aplicação \"{{ name }}\"?"
|
||||
@@ -619,7 +617,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Procurar por Atualizações",
|
||||
"schedule": "Agendar",
|
||||
"updateAvailableAction": "Disponível Atualização",
|
||||
"stopUpdateAction": "Parar Atualização",
|
||||
"disabled": "Desativada"
|
||||
@@ -633,8 +630,6 @@
|
||||
"blockingAppsInfo": "Por favor, aguarde que as operações em cima terminem."
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"days": "Dias",
|
||||
"hours": "Horas",
|
||||
"disableCheckbox": "Desativar Atualizações Automáticas",
|
||||
"enableCheckbox": "Ativar Atualizações Automáticas",
|
||||
"selectOne": "Selecione pelo menos um dia e hora"
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"displayName": "Отображаемое имя",
|
||||
"actions": "Действия",
|
||||
"table": {
|
||||
"version": "Версия"
|
||||
"version": "Версия",
|
||||
"created": "Создано"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Перезагрузка",
|
||||
@@ -51,7 +52,8 @@
|
||||
"next": "Следующий",
|
||||
"configure": "Настроить",
|
||||
"restart": "Перезапуск",
|
||||
"reset": "Сброс"
|
||||
"reset": "Сброс",
|
||||
"loadMore": "Загрузить ещё"
|
||||
},
|
||||
"searchPlaceholder": "Поиск",
|
||||
"multiselect": {
|
||||
@@ -103,6 +105,9 @@
|
||||
"appNotFoundDialog": {
|
||||
"title": "Приложение не найдено",
|
||||
"description": "Не найдено приложения <b>{{ appId }}</b> версии <b>{{ version }}</b>."
|
||||
},
|
||||
"action": {
|
||||
"addCustomApp": "Добавить стороннее приложение"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
@@ -278,18 +283,23 @@
|
||||
"disable": "Отключить"
|
||||
},
|
||||
"enable2FA": {
|
||||
"authenticatorAppDescription": "Используйте Google Authenticator<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) или аналогичные TOTP приложения для сканирования секретного кода.",
|
||||
"authenticatorAppDescription": "Используйте Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) или аналогичные TOTP приложения для сканирования секретного кода.",
|
||||
"title": "Включить двухфакторную аутентификацию (2FA)",
|
||||
"token": "Токен",
|
||||
"enable": "Включить",
|
||||
"mandatorySetup": "Для доступа к панели управления требуется 2FA. Пожалуйста, закончите настройку, чтобы продолжить."
|
||||
"mandatorySetup": "Для доступа к панели управления требуется 2FA. Пожалуйста, закончите настройку, чтобы продолжить.",
|
||||
"passkeyOption": "Ключ доступа",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Настроить ключ доступа",
|
||||
"passkeyDescription": "Браузер предложит вам создать ключ доступа с помощью биометрических данных вашего устройства или менеджера паролей."
|
||||
},
|
||||
"appPasswords": {
|
||||
"description": "Пароли приложений - это мера безопасности, направленная на защиту вашего аккаунта Cloudron от несанкционированного доступа. Если вам необходим доступ к Cloudron с ненадёжного мобильного или десктопного приложения, вы можете войти под своим именем пользователя и использовать с ним специально сгенерированный пароль.",
|
||||
"title": "Пароли приложений",
|
||||
"app": "Приложение",
|
||||
"name": "Имя",
|
||||
"noPasswordsPlaceholder": "Пароли приложений отсутствуют"
|
||||
"noPasswordsPlaceholder": "Пароли приложений отсутствуют",
|
||||
"expires": "Истекает"
|
||||
},
|
||||
"title": "Профиль",
|
||||
"primaryEmail": "Основной Email",
|
||||
@@ -326,7 +336,8 @@
|
||||
"name": "Имя пароля",
|
||||
"app": "Приложение",
|
||||
"description": "Используйте этот пароль для аутентификации в приложении:",
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности."
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности.",
|
||||
"expiresAt": "Истекает в"
|
||||
},
|
||||
"createApiToken": {
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный API Токен. Он не будет показан снова из соображений безопасности.",
|
||||
@@ -337,8 +348,6 @@
|
||||
"allowedIpRanges": "Разрешённые диапазоны IP"
|
||||
},
|
||||
"changePasswordAction": "Изменить пароль",
|
||||
"disable2FAAction": "Выключить 2FA",
|
||||
"enable2FAAction": "Включить 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Письмо отправлено на адрес электронной почты {{ email }}"
|
||||
},
|
||||
@@ -349,6 +358,11 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Удалить пароль приложения",
|
||||
"description": "Удалить пароль приложения \"{{ name }}\" ?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Двухфакторная аутентификация",
|
||||
"totpEnabled": "Используется одноразовый пароль (TOTP)",
|
||||
"passkeyEnabled": "Используется ключ доступа"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
@@ -362,21 +376,22 @@
|
||||
"customAppUpdateInfo": "Для сторонних приложений автообновления недоступны.",
|
||||
"description": "Название & версия приложения",
|
||||
"appId": "ID приложения",
|
||||
"packageVersion": "Версия контейнера",
|
||||
"packageVersion": "Версия пакета",
|
||||
"lastUpdated": "Обновлен",
|
||||
"installedAt": "Установлено"
|
||||
"installedAt": "Установлено",
|
||||
"packager": "Сборщик"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Автоматические обновления",
|
||||
"description": "Обновления приложения применяются периодически, в соответствии с <a href=\"/#/system-update\">расписанием обновлений</a>"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron автоматически проверяет Магазин приложений на наличие обновлений. Вы также можете проверить их вручную."
|
||||
"description": "Cloudron автоматически проверяет наличие обновлений для приложений. Вы также можете проверить их вручную."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"backups": {
|
||||
"description": "Создать полный снимок приложения.",
|
||||
"description": "Создать полный снимок приложения",
|
||||
"title": "Резервные копии",
|
||||
"downloadConfigTooltip": "Скачать конфигурацию",
|
||||
"cloneTooltip": "Клонировать",
|
||||
@@ -388,7 +403,7 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Импортировать",
|
||||
"description": "Импортировать приложение из внешней резервной копии."
|
||||
"description": "Импортировать приложение из внешней резервной копии"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Автоматические резервные копии",
|
||||
@@ -404,8 +419,7 @@
|
||||
"saveAction": "Сохранить",
|
||||
"aliases": "Псевдонимы",
|
||||
"addAliasAction": "Добавить псевдоним",
|
||||
"noAliases": "Домены-псевдонимы отсутствуют",
|
||||
"dnsoverwrite": "Некоторые DNS записи уже существуют. Подтвердите перезапись."
|
||||
"noAliases": "Домены-псевдонимы отсутствуют"
|
||||
},
|
||||
"accessControl": {
|
||||
"sftp": {
|
||||
@@ -416,10 +430,10 @@
|
||||
},
|
||||
"operators": {
|
||||
"title": "Операторы",
|
||||
"description": "Операторы могут настраивать и поддерживать работу этого приложения."
|
||||
"description": "Настроить, кто может поддерживать работу приложения"
|
||||
},
|
||||
"userManagement": {
|
||||
"description": "Настроить, кто может входить и использовать это приложение.",
|
||||
"description": "Настроить, кто может входить и использовать это приложение",
|
||||
"descriptionSftp": "Данный параметр также контролирует доступ к SFTP.",
|
||||
"dashboardVisibility": "Видимость в панели управления",
|
||||
"visibleForAllUsers": "Отображать для всех пользователей Cloudron",
|
||||
@@ -503,7 +517,7 @@
|
||||
"hourly": "Каждый час",
|
||||
"service": "Проверить (один запуск)"
|
||||
},
|
||||
"addCommonPattern": "Добавить общий шаблон",
|
||||
"addCommonPattern": "Вставить общий шаблон",
|
||||
"description": "Задания Cron, требуемые для правильной работы приложения, уже интегрированы в контейнер. Здесь можно настроить прочие задания."
|
||||
},
|
||||
"display": {
|
||||
@@ -553,11 +567,27 @@
|
||||
"csp": {
|
||||
"title": "Политика безопасности контента",
|
||||
"saveAction": "Сохранить",
|
||||
"description": "Перезаписать любые CSP заголовки, отправляемые приложением."
|
||||
"description": "Перезаписать любые CSP заголовки, отправляемые приложением",
|
||||
"insertCommonCsp": "Вставить стандартный CSP",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Разрешить встраивание",
|
||||
"sameOriginEmbedding": "Разрешить встраивание (только поддомены)",
|
||||
"allowCdnAssets": "Разрешить использование ресурсов CDN",
|
||||
"reportOnly": "Сообщить о нарушениях CSP",
|
||||
"strictBaseline": "Строгий базовый уровень"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"description": "По умолчанию, роботы могут индексировать это приложение."
|
||||
"description": "По умолчанию, роботы могут индексировать это приложение",
|
||||
"commonPattern": {
|
||||
"allowAll": "Разрешить все (по умолчанию)",
|
||||
"disallowAll": "Запретить все",
|
||||
"disallowCommonBots": "Запретить известных ботов",
|
||||
"disallowAdminPaths": "Запретить пути админа",
|
||||
"disallowApiPaths": "Запретить пути API"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Вставить стандартный robots.txt"
|
||||
},
|
||||
"hstsPreload": "Активировать предзагрузку HSTS (в том числе для поддоменов)"
|
||||
},
|
||||
@@ -580,11 +610,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"description": "Вместо удаления, приложение может быть остановлено для освобождения ресурсов сервера. Будущие резервные копии не сохранят текущее состояние приложения до момента остановки. Рекомендуется запустить процесс резервного копирования вручную до остановки работы приложения.",
|
||||
"startAction": "Запустить",
|
||||
"stopAction": "Остановить"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Удаление",
|
||||
"description": "Удалить приложение и все его данные. Резервные копии очищаются в соответствии с политикой резервного копирования.",
|
||||
@@ -627,7 +652,7 @@
|
||||
"cloneDialog": {
|
||||
"title": "Клонировать приложение",
|
||||
"location": "Расположение",
|
||||
"description": "Клон использует резервную копию версии <b>v{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
|
||||
"description": "Клон использует резервную копию версии <b>{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
|
||||
},
|
||||
"addApplinkDialog": {
|
||||
"title": "Добавить Внешнюю ссылку"
|
||||
@@ -670,6 +695,16 @@
|
||||
"forumAction": "Форум",
|
||||
"appLink": {
|
||||
"title": "Внешняя ссылка"
|
||||
},
|
||||
"start": {
|
||||
"title": "Старт",
|
||||
"description": "Запустить приложение и сделать его снова доступным.",
|
||||
"action": "Старт"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Стоп",
|
||||
"title": "Стоп",
|
||||
"description": "Остановить приложение, чтобы сохранить ресурсы. Создайте резервную копию перед этим, чтобы сохранить последние изменения."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -686,7 +721,8 @@
|
||||
"tooltipDownloadBackupConfig": "Скачать конфигурацию",
|
||||
"cleanupBackups": "Очистить резервные копии",
|
||||
"backupNow": "Создать копию",
|
||||
"tooltipPreservedBackup": "Резервная копия будет сохранена"
|
||||
"tooltipPreservedBackup": "Резервная копия будет сохранена",
|
||||
"description": "Системные резервные копии содержат настройки Cloudron и метаданные приложений. Они могут быть использованы для <a href=\"{{restoreLink}}\" target=\"_blank\">восстановления</a> или <a href=\"{{migrateLink}}\" target=\"_blank\">переноса</a> Cloudron на другой сервер."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Расписание & политика хранения",
|
||||
@@ -772,11 +808,14 @@
|
||||
"title": "Резервные копии",
|
||||
"backupDetails": {
|
||||
"title": "Детали резервного копирования",
|
||||
"id": "Id",
|
||||
"date": "Дата",
|
||||
"version": "Версия",
|
||||
"id": "ID Резервной копии",
|
||||
"date": "Создано",
|
||||
"version": "Версия пакета",
|
||||
"size": "Размер",
|
||||
"duration": "Продолжительность"
|
||||
"duration": "Продолжительность резервного копирования",
|
||||
"lastIntegrityCheck": "Последняя проверка целостности",
|
||||
"integrityNever": "никогда",
|
||||
"integrityInProgress": "В процессе"
|
||||
},
|
||||
"backupEdit": {
|
||||
"title": "Редактировать резервную копию",
|
||||
@@ -818,7 +857,9 @@
|
||||
"title": "Настроить содержание резервной копии"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Используется шифрование файлов и их имён",
|
||||
"useFileEncryption": "Используется шифрование файлов"
|
||||
"useFileEncryption": "Используется шифрование файлов",
|
||||
"checkIntegrity": "Проверить целостность",
|
||||
"stopIntegrity": "Остановить проверку целостности"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Брендирование",
|
||||
@@ -1005,17 +1046,13 @@
|
||||
"updateAvailableAction": "Доступно обновление",
|
||||
"stopUpdateAction": "Остановить обновление",
|
||||
"description": "Обновления платформы и приложений запускаются с учётом установленного расписания и в соответствии с <a href=\"/#/system-settings\">системным часовым поясом</a>.",
|
||||
"schedule": "Расписание обновлений",
|
||||
"disabled": "Выключено",
|
||||
"onLatest": "последний"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Настроить расписание автоматических обновлений",
|
||||
"disableCheckbox": "Выключить автоматические обновления",
|
||||
"enableCheckbox": "Включить автоматические обновления",
|
||||
"selectOne": "Выберите по крайней мере один день и время",
|
||||
"days": "Дни",
|
||||
"hours": "Часы",
|
||||
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с расписанием резервного копирования."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1092,7 +1129,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Домен панели управления",
|
||||
"changeAction": "Изменить домен",
|
||||
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена"
|
||||
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена",
|
||||
"confirmMessage": "Это действие сбросит ключи доступа для всех пользователей.",
|
||||
"confirmTitle": "Вы точно хотите сменить домен панели управления?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"editTitle": "Редактировать домен",
|
||||
@@ -1150,7 +1189,9 @@
|
||||
"inwxUsername": "Имя пользователя INWX",
|
||||
"inwxPassword": "Пароль INWX",
|
||||
"customNameservers": "Домен использует пользовательские серверы имён (vanity)",
|
||||
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен."
|
||||
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен.",
|
||||
"carddavLocation": "Расположение сервера CardDAV",
|
||||
"caldavLocation": "Расположение сервера CalDAV"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Удалить домен",
|
||||
@@ -1189,7 +1230,12 @@
|
||||
"allCaughtUp": "Уведомления отсутствуют",
|
||||
"settingsDialog": {
|
||||
"description": "Уведомления о выбранных событиях будут отправлены на основной Email."
|
||||
}
|
||||
},
|
||||
"title": "Уведомления",
|
||||
"showAll": "Все",
|
||||
"showUnread": "Непрочитанные",
|
||||
"markUnread": "Отметить как непрочитанные",
|
||||
"markRead": "Отметить как прочитанные"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Логи",
|
||||
@@ -1210,7 +1256,7 @@
|
||||
"filemanager": {
|
||||
"title": "Файловый менеджер",
|
||||
"newDirectoryDialog": {
|
||||
"title": "Имя новой папки",
|
||||
"title": "Новая папка",
|
||||
"create": "Создать"
|
||||
},
|
||||
"newFileDialog": {
|
||||
@@ -1232,7 +1278,8 @@
|
||||
"restartApp": "Перезагрузить приложение",
|
||||
"uploadFolder": "Загрузить папку",
|
||||
"openTerminal": "Открыть терминал",
|
||||
"openLogs": "Открыть логи"
|
||||
"openLogs": "Открыть логи",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"removeDialog": {
|
||||
"reallyDelete": "Действительно удалить?"
|
||||
@@ -1241,7 +1288,7 @@
|
||||
"pasteInProgress": "Выполняется копирование / перемещение",
|
||||
"deleteInProgress": "Выполняется удаление",
|
||||
"chownDialog": {
|
||||
"title": "Смена владельца",
|
||||
"title": "Изменить владельца",
|
||||
"newOwner": "Новый владелец",
|
||||
"change": "Изменить владельца",
|
||||
"recursiveCheckbox": "Изменить владельца рекурсивно"
|
||||
@@ -1272,7 +1319,7 @@
|
||||
"symlink": "Символическая ссылка на {{ target }}",
|
||||
"menu": {
|
||||
"rename": "Переименовать",
|
||||
"chown": "Изменить владельца",
|
||||
"chown": "Смена владельца",
|
||||
"extract": "Распаковать здесь",
|
||||
"download": "Скачать",
|
||||
"delete": "Удалить",
|
||||
@@ -1464,7 +1511,8 @@
|
||||
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
|
||||
"errorIncorrect2FAToken": "Неверный 2FA токен",
|
||||
"errorInternal": "Внутренняя ошибка, попробуйте позже",
|
||||
"loginAction": "Войти"
|
||||
"loginAction": "Войти",
|
||||
"usePasskeyAction": "Использовать ключ доступа"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Сброс пароля",
|
||||
@@ -1608,7 +1656,8 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Архивные приложения отсутствуют"
|
||||
}
|
||||
},
|
||||
"description": "В архивированном приложении сохраняется его последняя резервная копия. Эта копия хранится постоянно и может быть восстановлена в любой момент."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
@@ -1619,7 +1668,9 @@
|
||||
"sites": {
|
||||
"title": "Локации резервных копий",
|
||||
"emptyPlaceholder": "Локации отсутствуют",
|
||||
"lastRun": "Последний запуск"
|
||||
"lastRun": "Последний запуск",
|
||||
"description": "Локации резервных копий указывают на то, где будут сохраняться копии системы и приложений. Резервные копии приложений могут быть восстановлены по-отдельности.",
|
||||
"noAutomaticUpdateBackupWarning": "Не настроено ни одной локации резервных копий для хранения копий автоматических обновлений. Включите \"Хранить бэкапы автоматических обновлений здесь\" по крайней мере в одной локации, чтобы активировать автоматические обновления."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1658,5 +1709,9 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "Сервер"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Cloudron не проводит аудит приложений, созданных сообществом. Устанавливайте приложения только от проверенных разработчиков. Сторонний код может поставить под угрозу безопасности вашей системы.",
|
||||
"unstablewarning": "Разработчик пометил это приложение как нестабильное."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,6 @@
|
||||
"copyNow": "请复制 API Token。出于安全考虑,这个 API Token 未来不会再显示。"
|
||||
},
|
||||
"changePasswordAction": "修改密码",
|
||||
"disable2FAAction": "停用双因素验证",
|
||||
"enable2FAAction": "启用双因素验证",
|
||||
"title": "个人资料",
|
||||
"primaryEmail": "主要 Email",
|
||||
"passwordRecoveryEmail": "密码恢复 Email",
|
||||
@@ -534,12 +532,9 @@
|
||||
"stopUpdateAction": "停止更新"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "配置自动更新时间表",
|
||||
"disableCheckbox": "停用自动更新",
|
||||
"enableCheckbox": "启用自动更新",
|
||||
"selectOne": "选择至少一个日期和时间",
|
||||
"days": "星期",
|
||||
"hours": "小时",
|
||||
"description": "选择检查平台和应用更新的日子和时间。请注意这个时间不要和 <a href=\"/#/backups\">备份时间</a> 冲突。"
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1034,11 +1029,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "启动应用",
|
||||
"description": "可以通过停止应用(而非卸载)来节省服务器资源。停用后的自动备份不会包括当前的状态,有鉴于此,建议你在停止应用之前进行一次手动备份。",
|
||||
"stopAction": "停止应用"
|
||||
},
|
||||
"uninstall": {
|
||||
"description": "将会卸载此应用,并删除所有数据。卸载后该应用将不可用。",
|
||||
"title": "卸载",
|
||||
|
||||
@@ -5,14 +5,16 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher } from '@cloudron/pankow';
|
||||
import { Notification, InputDialog, fetcher } from '@cloudron/pankow';
|
||||
import { setLanguage } from './i18n.js';
|
||||
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
|
||||
import { redirectIfNeeded } from './utils.js';
|
||||
import { redirectIfNeeded, startAuthFlow } from './utils.js';
|
||||
import ProfileModel from './models/ProfileModel.js';
|
||||
import ProvisionModel from './models/ProvisionModel.js';
|
||||
import NotificationsModel from './models/NotificationsModel.js';
|
||||
import DashboardModel from './models/DashboardModel.js';
|
||||
import BrandingModel from './models/BrandingModel.js';
|
||||
import AppstoreModel from './models/AppstoreModel.js';
|
||||
import Headerbar from './components/Headerbar.vue';
|
||||
import SubscriptionRequiredDialog from './components/SubscriptionRequiredDialog.vue';
|
||||
import RequestErrorDialog from './components/RequestErrorDialog.vue';
|
||||
@@ -34,6 +36,7 @@ import EmailSettingsView from './views/EmailSettingsView.vue';
|
||||
import EmailEventlogView from './views/EmailEventlogView.vue';
|
||||
import EventlogView from './views/EventlogView.vue';
|
||||
import NetworkView from './views/NetworkView.vue';
|
||||
import NotificationsView from './views/NotificationsView.vue';
|
||||
import ProfileView from './views/ProfileView.vue';
|
||||
import ServicesView from './views/ServicesView.vue';
|
||||
import SystemSettingsView from './views/SystemSettingsView.vue';
|
||||
@@ -64,6 +67,7 @@ const VIEWS = Object.freeze({
|
||||
EMAIL_EVENTLOG: '#/email-eventlog',
|
||||
SERVER: '#/server',
|
||||
NETWORK: '#/network',
|
||||
NOTIFICATIONS: '#/notifications',
|
||||
PROFILE: '#/profile',
|
||||
SERVICES: '#/services',
|
||||
SYSTEM_SETTINGS: '#/system-settings',
|
||||
@@ -273,12 +277,16 @@ fetcher.globalOptions.errorHook = (error) => {
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
|
||||
const ready = ref(false);
|
||||
const view = ref('');
|
||||
const profile = ref({});
|
||||
const dashboardDomain = ref('');
|
||||
const notificationCount = ref(0);
|
||||
const subscription = ref({
|
||||
plan: {},
|
||||
});
|
||||
@@ -319,6 +327,8 @@ function onHashChange() {
|
||||
view.value = VIEWS.EMAIL_EVENTLOG;
|
||||
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SERVER;
|
||||
} else if (v === VIEWS.NOTIFICATIONS && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.NOTIFICATIONS;
|
||||
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.NETWORK;
|
||||
} else if (v === VIEWS.PROFILE) {
|
||||
@@ -381,6 +391,9 @@ async function refreshConfigAndFeatures() {
|
||||
console.log('Dashboard version changed, reloading');
|
||||
localStorage.setItem('version', result.version);
|
||||
window.location.reload(true);
|
||||
|
||||
// return never ending promise to just wait for the reload
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
config.value = result;
|
||||
@@ -388,6 +401,20 @@ async function refreshConfigAndFeatures() {
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
}
|
||||
|
||||
async function refreshNotifications() {
|
||||
const [error, result] = await notificationModel.list(false);
|
||||
if (error) return console.error(error);
|
||||
notificationCount.value = result.length;
|
||||
}
|
||||
|
||||
async function refreshSubscription() {
|
||||
const [error, result] = await appstoreModel.getSubscription();
|
||||
if (error && error.status === 402) console.error('Not yet registered');
|
||||
else if (error && error.status === 412) window.location.href = ''
|
||||
else if (error) console.error(error);
|
||||
else subscription.value = result;
|
||||
}
|
||||
|
||||
async function onOnline() {
|
||||
ready.value = true;
|
||||
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
|
||||
@@ -399,12 +426,15 @@ function checkForMobile() {
|
||||
}
|
||||
|
||||
provide('subscriptionRequiredDialog', subscriptionRequiredDialog);
|
||||
provide('subscription', subscription);
|
||||
provide('features', features);
|
||||
provide('profile', profile);
|
||||
provide('refreshProfile', refreshProfile);
|
||||
provide('refreshFeatures', refreshConfigAndFeatures);
|
||||
provide('refreshNotifications', refreshNotifications);
|
||||
provide('dashboardDomain', dashboardDomain);
|
||||
provide('isMobile', isMobile);
|
||||
provide('inputDialog', inputDialog);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
@@ -417,29 +447,36 @@ onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
localStorage.setItem('redirectToHash', window.location.hash);
|
||||
|
||||
// start oidc flow
|
||||
window.location.href = `${API_ORIGIN}/openid/auth?client_id=` + (API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
const clientId = API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN;
|
||||
window.location.href = await startAuthFlow(clientId, API_ORIGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshConfigAndFeatures();
|
||||
await refreshProfile();
|
||||
|
||||
// ensure language from profile if set
|
||||
if (profile.value.language) await setLanguage(profile.value.language, true);
|
||||
|
||||
await refreshConfigAndFeatures();
|
||||
|
||||
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
|
||||
|
||||
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}`);
|
||||
|
||||
if (profile.value.isAtLeastAdmin) {
|
||||
refreshNotifications();
|
||||
refreshSubscription();
|
||||
}
|
||||
|
||||
ready.value = true;
|
||||
|
||||
// when done, redirect the user to setup 2fa if it is mandatory and neither totp nor passkey is setup
|
||||
if (config.value.mandatory2FA && !profile.value.totpEnabled && !profile.value.hasPasskey) {
|
||||
return window.location.href = VIEWS.PROFILE;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -454,12 +491,13 @@ onUnmounted(() => {
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
|
||||
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
|
||||
<RequestErrorDialog/>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
|
||||
<div v-if="ready" style="display: flex; flex-direction: row; overflow: hidden; height: 100%;">
|
||||
<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"/>
|
||||
<Headerbar :config="config" :notification-count="notificationCount"/>
|
||||
|
||||
<div style="display: flex; justify-content: center; overflow: auto; flex-grow: 1; padding: 0; margin: 0 10px; position: relative;">
|
||||
<KeepAlive>
|
||||
@@ -481,6 +519,7 @@ onUnmounted(() => {
|
||||
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
|
||||
<ServerView v-else-if="view === VIEWS.SERVER" />
|
||||
<NetworkView v-else-if="view === VIEWS.NETWORK" />
|
||||
<NotificationsView v-else-if="view === VIEWS.NOTIFICATIONS" />
|
||||
<ProfileView v-else-if="view === VIEWS.PROFILE" />
|
||||
<ServicesView v-else-if="view === VIEWS.SERVICES" />
|
||||
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
|
||||
|
||||
@@ -43,7 +43,7 @@ const cloudronAuth = computed(() => {
|
||||
<template>
|
||||
<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>
|
||||
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-control" 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>
|
||||
@@ -52,7 +52,7 @@ const cloudronAuth = computed(() => {
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<FormGroup>
|
||||
<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>
|
||||
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" 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>
|
||||
|
||||
@@ -66,7 +66,7 @@ const cloudronAuth = computed(() => {
|
||||
|
||||
<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" />
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :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" />
|
||||
|
||||
@@ -11,6 +11,8 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const quickActions = computed(() => {
|
||||
if (window.innerWidth <= 576) return [];
|
||||
|
||||
const visibleActions = props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator);
|
||||
if (visibleActions.length <= 2) return visibleActions;
|
||||
|
||||
@@ -35,7 +37,7 @@ function onMenu(event) {
|
||||
<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 v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action && 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 }"/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import TokensModel from '../models/TokensModel.js';
|
||||
const tokensModel = TokensModel.create();
|
||||
|
||||
const apiTokens = ref([]);
|
||||
const loading = ref(true);
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const newDialog = useTemplateRef('newDialog');
|
||||
const addedToken = ref('');
|
||||
@@ -122,6 +123,7 @@ async function onRevokeToken(apiToken) {
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshApiTokens();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -184,20 +186,20 @@ onMounted(async () => {
|
||||
<div v-html="$t('profile.apiTokens.description', { apiDocsLink: 'https://docs.cloudron.io/api.html' })"></div>
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="apiTokens" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
|
||||
<template #lastUsedTime="apiToken">
|
||||
<TableView :columns="columns" :model="apiTokens" :busy="loading" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
|
||||
<template #lastUsedTime="{ item:apiToken }">
|
||||
<span v-if="apiToken.lastUsedTime">{{ prettyLongDate(apiToken.lastUsedTime) }}</span>
|
||||
<span v-else>{{ $t('profile.apiTokens.neverUsed') }}</span>
|
||||
</template>
|
||||
<template #scope="apiToken">
|
||||
<template #scope="{ item:apiToken }">
|
||||
<span v-if="apiToken.scope['*'] === 'rw'">{{ $t('profile.apiTokens.readwrite') }}</span>
|
||||
<span v-else>{{ $t('profile.apiTokens.readonly') }}</span>
|
||||
</template>
|
||||
<template #allowedIpRanges="apiToken">
|
||||
<template #allowedIpRanges="{ item:apiToken }">
|
||||
<span v-if="apiToken.allowedIpRanges !== ''">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-else>{{ '*' }}</span>
|
||||
</template>
|
||||
<template #actions="apiToken">
|
||||
<template #actions="{ item:apiToken }">
|
||||
<ActionBar :actions="createActionMenu(apiToken)" />
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { ref, computed, useTemplateRef, onMounted, inject, watch } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
|
||||
import { Button, Checkbox, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
import AppsModel from '../models/AppsModel.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';
|
||||
import { API_ORIGIN, PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const STEP = Object.freeze({
|
||||
LOADING: Symbol('loading'),
|
||||
@@ -19,7 +18,6 @@ const STEP = Object.freeze({
|
||||
INSTALL: Symbol('install'),
|
||||
});
|
||||
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -31,7 +29,10 @@ const dashboardDomain = inject('dashboardDomain');
|
||||
// reactive
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const app = ref({});
|
||||
|
||||
// community { iconUrl, versionsUrl, manifest, publishState, creationDate, ts }
|
||||
// appstore { id, iconUrl, appStoreId, manifest, creationDate, publishState }
|
||||
const packageData = ref({});
|
||||
const manifest = ref({});
|
||||
const step = ref(STEP.DETAILS);
|
||||
const dialog = useTemplateRef('dialogHandle');
|
||||
@@ -39,23 +40,34 @@ const locationInput = useTemplateRef('locationInput');
|
||||
const description = computed(() => marked.parse(manifest.value.description || ''));
|
||||
const domains = ref([]);
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (!domain.value) return false;
|
||||
const form = ref(null); // assigned via "Function Ref" because it is inside v-if
|
||||
const isFormValid = ref(false);
|
||||
function resetDnsOverwrite() {
|
||||
needsOverwriteDns.value = [];
|
||||
overwriteDns.value = false;
|
||||
formError.value = {};
|
||||
}
|
||||
|
||||
if (location.value && !isValidDomain(location.value + '.' + domain.value)) return false;
|
||||
async function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
|
||||
if (isFormValid.value) {
|
||||
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) {
|
||||
isFormValid.value = true;
|
||||
}
|
||||
|
||||
if (manifest.value.id === PROXY_APP_ID) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
if (manifest.value.id === PROXY_APP_ID) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
watch(form, () => { // trigger form validation when the ref becomes set
|
||||
setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
const appMaxCountExceeded = ref(false);
|
||||
@@ -68,7 +80,9 @@ function setStep(newStep) {
|
||||
}
|
||||
|
||||
step.value = newStep;
|
||||
if (newStep === STEP.INSTALL) setTimeout(() => locationInput.value.$el.focus(), 500);
|
||||
if (newStep === STEP.INSTALL) {
|
||||
setTimeout(() => locationInput.value.$el.focus(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// form data
|
||||
@@ -81,7 +95,8 @@ const tcpPorts = ref({});
|
||||
const udpPorts = ref({});
|
||||
const secondaryDomains = ref({});
|
||||
const upstreamUri = ref('');
|
||||
const needsOverwriteDns = ref(false);
|
||||
const overwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref([]);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
@@ -90,7 +105,9 @@ function onDomainChange() {
|
||||
domainProvider.value = tmp ? tmp.provider : '';
|
||||
}
|
||||
|
||||
async function onSubmit(overwriteDns) {
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
@@ -101,6 +118,7 @@ async function onSubmit(overwriteDns) {
|
||||
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].value });
|
||||
|
||||
const conflicting = [];
|
||||
for (const d of checkForDomains) {
|
||||
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
|
||||
if (error) {
|
||||
@@ -109,12 +127,14 @@ async function onSubmit(overwriteDns) {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
if (result.needsOverwrite && !overwriteDns) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = true;
|
||||
formError.value.dnsExists = `DNS record for ${d.subdomain}.${d.domain} already exists`;
|
||||
return;
|
||||
}
|
||||
if (result.needsOverwrite) conflicting.push((d.subdomain ? d.subdomain + '.' : '') + d.domain);
|
||||
}
|
||||
|
||||
if (conflicting.length > 0 && !overwriteDns.value) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = conflicting;
|
||||
formError.value.generic = `DNS records of ${conflicting.join(', ')} already exist`;
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
@@ -123,7 +143,7 @@ async function onSubmit(overwriteDns) {
|
||||
accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? null : accessRestrictionAcl.value)
|
||||
};
|
||||
|
||||
if (overwriteDns) config.overwriteDns = true;
|
||||
if (overwriteDns.value) config.overwriteDns = true;
|
||||
|
||||
if (manifest.value.optionalSso) config.sso = accessRestrictionOption.value !== ACL_OPTIONS.NOSSO;
|
||||
|
||||
@@ -148,12 +168,12 @@ async function onSubmit(overwriteDns) {
|
||||
|
||||
if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value;
|
||||
|
||||
const [error, result] = await appsModel.install(manifest.value, config);
|
||||
const [error, result] = await appsModel.install(packageData.value, config);
|
||||
|
||||
if (!error) {
|
||||
dialog.value.close();
|
||||
localStorage['confirmPostInstall_' + result.id] = true;
|
||||
return window.location.href = '/#/apps';
|
||||
if (manifest.value.postInstallMessage) localStorage['confirmPostInstall_' + result.id] = true;
|
||||
return window.location.href = `/#/app/${result.id}/info`;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
@@ -175,7 +195,7 @@ function onClose() {
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
result.forEach(u => { u.label = u.displayName || u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -201,41 +221,22 @@ 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(appId, version, appCountExceeded, domainList) {
|
||||
open: async function(pd, appCountExceeded, domainList) {
|
||||
busy.value = false;
|
||||
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;
|
||||
packageData.value = pd;
|
||||
appMaxCountExceeded.value = appCountExceeded;
|
||||
manifest.value = a.manifest;
|
||||
manifest.value = packageData.value.manifest;
|
||||
location.value = '';
|
||||
accessRestrictionOption.value = ACL_OPTIONS.ANY;
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
domainProvider.value = '';
|
||||
upstreamUri.value = '';
|
||||
needsOverwriteDns.value = '';
|
||||
overwriteDns.value = false;
|
||||
needsOverwriteDns.value = [];
|
||||
|
||||
domainList.forEach(d => {
|
||||
d.label = '.' + d.domain;
|
||||
@@ -246,8 +247,8 @@ defineExpose({
|
||||
// preselect with dashboard domain
|
||||
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
|
||||
|
||||
tcpPorts.value = a.manifest.tcpPorts;
|
||||
udpPorts.value = a.manifest.udpPorts;
|
||||
tcpPorts.value = manifest.value.tcpPorts;
|
||||
udpPorts.value = manifest.value.udpPorts;
|
||||
|
||||
// ensure we have value property
|
||||
for (const p in tcpPorts.value) {
|
||||
@@ -259,7 +260,7 @@ defineExpose({
|
||||
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
|
||||
}
|
||||
|
||||
secondaryDomains.value = a.manifest.httpPorts;
|
||||
secondaryDomains.value = manifest.value.httpPorts;
|
||||
for (const p in secondaryDomains.value) {
|
||||
const port = secondaryDomains.value[p];
|
||||
port.value = port.defaultValue;
|
||||
@@ -268,6 +269,7 @@ defineExpose({
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
step.value = STEP.DETAILS;
|
||||
dialog.value.open();
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
@@ -283,15 +285,14 @@ defineExpose({
|
||||
</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>
|
||||
<div>{{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}</div>
|
||||
<div>{{ manifest.title }} Version {{ app.manifest.upstreamVersion }}</div>
|
||||
<div><a :href="manifest.website" target="_blank">{{ manifest.website }}</a></div>
|
||||
<div class="summary" v-if="packageData.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>{{ manifest.title }}</div>
|
||||
<div><a :href="manifest.website" target="_blank">{{ manifest.title }}</a> {{ packageData.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ packageData.manifest.version }}</div>
|
||||
<div v-if="packageData.versionsUrl"><a :href="packageData.manifest.packagerUrl" target="_blank">{{ packageData.manifest.packagerName }}</a></div>
|
||||
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(packageData.creationDate) }) }}</div>
|
||||
<div>{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}</div>
|
||||
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}</div>
|
||||
</div>
|
||||
<img class="icon pankow-no-mobile" :src="app.iconUrl" />
|
||||
<img class="icon pankow-no-mobile" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
</div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="step === STEP.DETAILS">
|
||||
@@ -306,18 +307,15 @@ defineExpose({
|
||||
<div class="description" v-html="description"></div>
|
||||
</div>
|
||||
<div v-else-if="step === STEP.INSTALL">
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<div class="error-label" v-if="formError.dnsExists">{{ formError.dnsExists }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit(false)" autocomplete="off">
|
||||
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!formValid" />
|
||||
<input style="display: none;" type="submit" :disabled="busy" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': formError.location }">
|
||||
<label for="location">{{ $t('appstore.installDialog.location') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="location" ref="locationInput" v-model="location" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10"/>
|
||||
<TextInput id="location" ref="locationInput" v-model="location" @input="resetDnsOverwrite()" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange(); resetDnsOverwrite()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
|
||||
<div class="error-label" v-if="formError.location">{{ formError.location }}</div>
|
||||
@@ -327,22 +325,26 @@ defineExpose({
|
||||
<label :for="'secondaryDomainInput' + key">{{ port.title }}</label>
|
||||
<small>{{ port.description }}</small>
|
||||
<InputGroup>
|
||||
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" />
|
||||
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" @input="resetDnsOverwrite()" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" @select="resetDnsOverwrite()" required/>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-show="manifest.id === PROXY_APP_ID">
|
||||
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-if="manifest.id === PROXY_APP_ID">
|
||||
<label for="upstreamUri">Upstream URI</label>
|
||||
<TextInput id="upstreamUri" v-model="upstreamUri" />
|
||||
<TextInput id="upstreamUri" v-model="upstreamUri" required/>
|
||||
</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" :users="users" :groups="groups" :installation="true"/>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<Checkbox v-if="needsOverwriteDns.length" v-model="overwriteDns" style="margin-top: 10px" :label="$t('app.location.overwriteDns', { domains: needsOverwriteDns.join(', ') })"/>
|
||||
|
||||
<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>
|
||||
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
<Button @click="onSubmit()" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid || (needsOverwriteDns.length > 0 && !overwriteDns)" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -370,7 +372,6 @@ defineExpose({
|
||||
|
||||
.app-install-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { Button, ClipboardButton, DateTimeInput, 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';
|
||||
@@ -35,7 +34,16 @@ const columns = {
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
expiresAt: {
|
||||
label: t('profile.appPasswords.expires'),
|
||||
hideMobile: true,
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
actions: {}
|
||||
@@ -54,22 +62,31 @@ const addedPassword = ref('');
|
||||
const passwordName = ref('');
|
||||
const identifiers = ref([]);
|
||||
const identifier = ref('');
|
||||
const expiresAtDate = ref('');
|
||||
const minExpiresAt = new Date().toISOString().slice(0, 16);
|
||||
const addError = ref('');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
|
||||
const appsById = {};
|
||||
async function refresh() {
|
||||
const [error, result] = await appPasswordsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
// setup label for the table UI
|
||||
result.forEach(function (password) {
|
||||
if (password.identifier === 'mail') return password.label = password.identifier;
|
||||
const app = appsById[password.identifier];
|
||||
if (!app) return password.label = password.identifier + ' (App not found)';
|
||||
for (const password of result) {
|
||||
if (password.identifier === 'mail') {
|
||||
password.label = password.identifier;
|
||||
} else {
|
||||
const app = appsById[password.identifier];
|
||||
if (!app) return password.label = password.identifier + ' (App not found)';
|
||||
|
||||
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
|
||||
const labelSuffix = ftp ? ' - SFTP' : '';
|
||||
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
|
||||
});
|
||||
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
|
||||
const labelSuffix = ftp ? ' - SFTP' : '';
|
||||
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
|
||||
}
|
||||
|
||||
password.expired = password.expiresAt && new Date(password.expiresAt) < new Date();
|
||||
}
|
||||
|
||||
passwords.value = result;
|
||||
}
|
||||
@@ -84,7 +101,10 @@ function onReset() {
|
||||
setTimeout(() => {
|
||||
passwordName.value = '';
|
||||
identifier.value = '';
|
||||
expiresAtDate.value = '';
|
||||
addedPassword.value = '';
|
||||
addError.value = '';
|
||||
busy.value = false;
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
@@ -92,16 +112,26 @@ function onReset() {
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
addError.value = '';
|
||||
addedPassword.value = '';
|
||||
|
||||
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value);
|
||||
if (error) return console.error(error);
|
||||
const expiresAt = expiresAtDate.value ? new Date(expiresAtDate.value).toISOString() : null;
|
||||
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value, expiresAt);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
addError.value = error.body ? error.body.message : 'Internal error';
|
||||
return;
|
||||
}
|
||||
|
||||
addedPassword.value = result.password;
|
||||
passwordName.value = '';
|
||||
identifier.value = '';
|
||||
expiresAtDate.value = '';
|
||||
|
||||
await refresh();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onRemove(appPassword) {
|
||||
@@ -134,7 +164,7 @@ onMounted(async () => {
|
||||
if (app.manifest.addons.email) return;
|
||||
|
||||
const ftp = app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
|
||||
const sso = app.sso && (app.manifest.addons.ldap || app.manifest.addons.proxyAuth);
|
||||
const sso = app.sso && (app.manifest.addons.ldap || app.manifest.addons.oidc || app.manifest.addons.proxyAuth);
|
||||
|
||||
if (!ftp && !sso) return;
|
||||
|
||||
@@ -150,6 +180,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
await refresh();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -160,7 +191,8 @@ onMounted(async () => {
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-active="addedPassword || isFormValid"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="addedPassword || (!busy && isFormValid)"
|
||||
:confirm-label="addedPassword ? '' : $t('main.action.add')"
|
||||
confirm-style="primary"
|
||||
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
@@ -169,19 +201,27 @@ onMounted(async () => {
|
||||
@close="onReset()"
|
||||
>
|
||||
<div>
|
||||
<div class="error-label" v-show="addError">{{ addError }}</div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedPassword">
|
||||
<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/>
|
||||
</FormGroup>
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
|
||||
<TextInput id="passwordName" v-model="passwordName" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.createAppPassword.app') }}</label>
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.createAppPassword.app') }}</label>
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="expiresAt">{{ $t('profile.createAppPassword.expiresAt') }} (optional)</label>
|
||||
<DateTimeInput id="expiresAt" v-model="expiresAtDate" :min="minExpiresAt"/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -204,9 +244,15 @@ onMounted(async () => {
|
||||
<div>{{ $t('profile.appPasswords.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
|
||||
<template #actions="password">
|
||||
<TableView :columns="columns" :model="passwords" :busy="loading" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #name="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.name }}</span></template>
|
||||
<template #label="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.label }}</span></template>
|
||||
<template #creationTime="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ prettyLongDate(password.creationTime) }}</span></template>
|
||||
<template #expiresAt="{ item:password }">
|
||||
<span :class="{ 'text-muted': password.expired }" v-if="!password.expiresAt">-</span>
|
||||
<span :class="{ 'text-muted': password.expired }" v-else>{{ prettyLongDate(password.expiresAt) }}</span>
|
||||
</template>
|
||||
<template #actions="{ item:password }">
|
||||
<ActionBar :actions="createActionMenu(password)" />
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -208,6 +208,8 @@ defineExpose({
|
||||
<div class="error-label" v-show="formError.port">{{ formError.port }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="locationInput">{{ $t('app.cloneDialog.location') }}</label>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, FormGroup, InputDialog, MultiSelect, Radiobutton, TagInput, TextInput } from '@cloudron/pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
import { getDataURLFromFile } from '../utils.js';
|
||||
@@ -64,16 +64,17 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
const data = {
|
||||
label: label.value,
|
||||
upstreamUri: upstreamUri.value,
|
||||
tags: tags.value,
|
||||
};
|
||||
|
||||
if (label.value) data.label = label.value;
|
||||
|
||||
data.accessRestriction = null;
|
||||
if (accessRestrictionOption.value === 'groups') {
|
||||
data.accessRestriction = { users: [], groups: [] };
|
||||
data.accessRestriction.users = accessRestriction.value.users.map(function (u) { return u.id; });
|
||||
data.accessRestriction.groups = accessRestriction.value.groups.map(function (g) { return g.id; });
|
||||
data.accessRestriction.users = accessRestriction.value.users;
|
||||
data.accessRestriction.groups = accessRestriction.value.groups;
|
||||
}
|
||||
|
||||
if (iconFile === 'fallback') { // user reset the icon
|
||||
@@ -130,6 +131,7 @@ defineExpose({
|
||||
// fetch users and groups
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => { u.label = u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -177,7 +179,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<div>
|
||||
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
|
||||
<label>{{ $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"/>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +190,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $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>
|
||||
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<Radiobutton v-model="accessRestrictionOption" value="any" :label="$t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" value="groups" :label="$t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
<!-- <span class="label label-danger"v-show="accessRestrictionOption === 'groups' && !isAccessRestrictionValid(applinkDialogData)">{{ $t('appstore.installDialog.errorUserManagementSelectAtLeastOne') }}</span> -->
|
||||
@@ -197,10 +199,10 @@ defineExpose({
|
||||
<div v-if="accessRestrictionOption === 'groups'">
|
||||
<div style="margin-left: 20px; display: flex;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-label="username" :search-threshold="20" />
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-label="name" :search-threshold="20" />
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, computed, useTemplateRef } from 'vue';
|
||||
import { ClipboardAction, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -15,24 +15,39 @@ 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;
|
||||
const backupContentTableColumns = computed(() => {
|
||||
const columns = {
|
||||
label: {
|
||||
label: t('backups.listing.contents'),
|
||||
sort: true,
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort(a, b, A , B) {
|
||||
return A.stats?.upload?.size - B.stats?.upload?.size;
|
||||
fileCount: {
|
||||
label: t('backup.target.fileCount'),
|
||||
sort(a, b, A, B) {
|
||||
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
|
||||
},
|
||||
align: 'right',
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort(a, b, A , B) {
|
||||
return A.stats?.upload?.size - B.stats?.upload?.size;
|
||||
},
|
||||
align: 'right',
|
||||
},
|
||||
};
|
||||
|
||||
if (backup.value.lastIntegrityCheckTime || backup.value.integrityCheckTask) {
|
||||
columns.integrity = {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return columns;
|
||||
});
|
||||
|
||||
const backup = ref({ contents: [], validStats: false });
|
||||
const dialog = useTemplateRef('dialog');
|
||||
@@ -67,8 +82,11 @@ defineExpose({
|
||||
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 };
|
||||
const content = { id: null, label: null, fqdn: null, stats: null, integrityCheckStatus: null, lastIntegrityCheckTime: null, integrityCheckTask: null };
|
||||
content.stats = result.stats;
|
||||
content.integrityCheckStatus = result.integrityCheckStatus;
|
||||
content.lastIntegrityCheckTime = result.lastIntegrityCheckTime;
|
||||
content.integrityCheckTask = result.integrityCheckTask;
|
||||
if (match[1] === 'mail') {
|
||||
content.id = 'mail';
|
||||
content.label = 'Mail Server';
|
||||
@@ -132,25 +150,53 @@ defineExpose({
|
||||
<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>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.lastIntegrityCheck') }}</div>
|
||||
<div class="info-value">
|
||||
<a v-if="backup.integrityCheckTask?.active" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">{{ $t('backups.backupDetails.integrityInProgress') }}</a>
|
||||
<a v-else-if="backup.lastIntegrityCheckTime && backup.integrityCheckTask" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</a>
|
||||
<span v-else-if="backup.lastIntegrityCheckTime">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</span>
|
||||
<span v-else>{{ $t('backups.backupDetails.integrityNever') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(backup.integrityCheckStatus === 'failed' || backup.integrityCheckStatus === 'skipped') && backup.integrityCheckResult?.messages?.length">
|
||||
<div class="info-label" style="margin-bottom: 5px;">Integrity Issues</div>
|
||||
<textarea readonly rows="10" style="width: 100%; resize: vertical;" :value="backup.integrityCheckResult.messages.join('\n')"></textarea>
|
||||
</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">
|
||||
<template #label="{ item: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">
|
||||
<template #fileCount="{ item: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">
|
||||
<template #size="{ item: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>
|
||||
<template #integrity="{ item:content }">
|
||||
<a v-if="content.lastIntegrityCheckTime && content.integrityCheckTask" :href="`/logs.html?taskId=${content.integrityCheckTask.id}`" target="_blank" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
</a>
|
||||
<div v-else-if="content.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(async () => {
|
||||
<FormGroup v-if="provider === 'mountpoint'">
|
||||
<label for="mountPointInput">{{ $t('backups.configureBackupStorage.mountPoint') }}</label>
|
||||
<TextInput id="mountPointInput" v-model="providerConfig.mountPoint" placeholder="/mnt/backups" required/>
|
||||
<small class="helper-text" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></small>
|
||||
<small class="warning-label" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: 'https://docs.cloudron.io/backups/#user-managed-mount-point' })"></small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
@@ -172,12 +172,6 @@ onMounted(async () => {
|
||||
<SingleSelect id="blockDevicePath" v-if="provider === 'xfs'" v-model="providerConfig.mountOptionDiskPath" :options="xfsBlockDevices" option-label="label" option-key="path"/>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Disk -->
|
||||
<FormGroup v-if="provider === 'disk'">
|
||||
<label class="control-label">{{ $t('backups.configureBackupStorage.diskPath') }}</label>
|
||||
<TextInput id="mountOptionDiskPathInput" v-model="providerConfig.mountOptionDiskPath" placeholder="/dev/disk/by-uuid/uuid" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- SSHFS -->
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionPortInput">{{ $t('backups.configureBackupStorage.port') }}</label>
|
||||
|
||||
@@ -124,7 +124,7 @@ async function onSubmit() {
|
||||
data.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
|
||||
data.preserveAttributes = true;
|
||||
}
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs' || provider.value === 'disk') {
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
data.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
|
||||
data.preserveAttributes = true;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike, prettySiteLocation } from '../utils.js';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -205,7 +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>{{ prettySiteLocation(site) }}</div>
|
||||
<div>{{ site.locationLabel }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
|
||||
92
dashboard/src/components/CommunityAppDialog.vue
Normal file
92
dashboard/src/components/CommunityAppDialog.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup } from '@cloudron/pankow';
|
||||
import CommunityModel from '../models/CommunityModel.js';
|
||||
|
||||
const communityModel = CommunityModel.create();
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const urlInput = useTemplateRef('urlInput');
|
||||
|
||||
const formError = ref({});
|
||||
const versionsUrl = ref('');
|
||||
const busy = ref(false);
|
||||
const unstable = ref(false);
|
||||
|
||||
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 = {};
|
||||
|
||||
const [url, version] = versionsUrl.value.split('@'); // hidden feature that user can input with version
|
||||
const [error, result] = await communityModel.getApp(url, version || 'latest');
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
unstable.value = !!result.unstable;
|
||||
|
||||
const packageData = {
|
||||
...result, // { manifest, publishState, creationDate, ts, unstable, versionsUrl }
|
||||
versionsUrl: result.versionsUrl,
|
||||
iconUrl: result.manifest.iconUrl // compat with app store format
|
||||
};
|
||||
|
||||
emit('success', packageData);
|
||||
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open() {
|
||||
versionsUrl.value = '';
|
||||
formError.value = {};
|
||||
unstable.value = false;
|
||||
dialog.value.open();
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
setTimeout(() => urlInput.value.focus(), 500);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
title="Install Community App"
|
||||
:confirm-label="$t('main.action.add')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" />
|
||||
|
||||
<div class="warning-label">{{ $t('communityapp.installwarning') }}</div>
|
||||
<div class="error-label" v-if="unstable">{{ $t('communityapp.unstablewarning') }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="urlInput">CloudronVersions.json URL</label>
|
||||
<TextInput id="urlInput" ref="urlInput" v-model="versionsUrl" required placeholder="https://example.com/CloudronVersions.json"/>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, inject } from 'vue';
|
||||
import { Button, ProgressBar, SingleSelect, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
@@ -14,6 +18,8 @@ const taskModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const inputDialog = inject('inputDialog');
|
||||
|
||||
const domains = ref([]);
|
||||
const formError = ref('');
|
||||
const originalDomain = ref('');
|
||||
@@ -64,6 +70,16 @@ async function refreshTasks() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const confirm = await inputDialog.value.confirm({
|
||||
title: t('domains.changeDashboardDomain.confirmTitle'),
|
||||
message: t('domains.changeDashboardDomain.confirmMessage'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
if (!confirm) return;
|
||||
|
||||
formError.value = '';
|
||||
|
||||
lastTask.value.active = true;
|
||||
|
||||
@@ -12,6 +12,7 @@ const dialog = useTemplateRef('dialog');
|
||||
const formError = ref({});
|
||||
const busy = ref (false);
|
||||
const password = ref('');
|
||||
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
@@ -25,10 +26,19 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
|
||||
const [error] = await profileModel.disableTwoFA(password.value);
|
||||
let error;
|
||||
if (twoFAMethod.value === 'passkey') {
|
||||
[error] = await profileModel.deletePasskey(password.value);
|
||||
} else {
|
||||
[error] = await profileModel.disableTwoFA(password.value);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error.status === 412) formError.value.password = error.body.message;
|
||||
else {
|
||||
if (error.status === 412) {
|
||||
password.value = '';
|
||||
formError.value.password = error.body.message;
|
||||
setTimeout(() => document.getElementById('passwordInput')?.focus(), 0);
|
||||
} else {
|
||||
formError.value.generic = error.status ? error.body.message : 'Internal error';
|
||||
console.error('Failed to disable 2fa', error);
|
||||
}
|
||||
@@ -46,7 +56,8 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
async open(method = 'totp') {
|
||||
twoFAMethod.value = method;
|
||||
password.value = '';
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
@@ -60,11 +71,11 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('profile.disable2FA.title')"
|
||||
:title="twoFAMethod === 'totp' ? $t('profile.disableTotp.title') : $t('profile.disablePasskey.title')"
|
||||
:confirm-label="$t('profile.disable2FA.disable')"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
confirm-style="primary"
|
||||
confirm-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@@ -78,7 +89,7 @@ defineExpose({
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.disable2FA.password') }}</label>
|
||||
<PasswordInput v-model="password" required />
|
||||
<PasswordInput v-model="password" required id="passwordInput" />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -75,10 +75,4 @@ onMounted(async () => {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.disks-last-updated {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,8 @@ async function getUsage() {
|
||||
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
|
||||
showingCachedValue.value = false;
|
||||
|
||||
contents.value = [];
|
||||
|
||||
eventSource = result;
|
||||
@@ -36,7 +38,6 @@ async function getUsage() {
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
ts.value = Date.now();
|
||||
showingCachedValue.value = false;
|
||||
|
||||
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
|
||||
contents.value.sort((a, b) => b.usage - a.usage);
|
||||
@@ -176,7 +177,7 @@ onUnmounted(() => {
|
||||
.disk-item-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -224,7 +225,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
tr.highlight {
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -104,7 +104,7 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="registries" :busy="busy" :placeholder="$t('dockerRegistries.emptyPlaceholder')">
|
||||
<template #actions="registry">
|
||||
<template #actions="{ item:registry }">
|
||||
<ActionBar :actions="createActionMenu(registry)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -53,21 +53,13 @@ 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 = '';
|
||||
dnsConfig.value.apiUrl = '';
|
||||
dnsConfig.value.accessToken = '';
|
||||
dnsConfig.value.apiKey = '';
|
||||
dnsConfig.value.apikey = '';
|
||||
dnsConfig.value.appKey = '';
|
||||
dnsConfig.value.appSecret = '';
|
||||
dnsConfig.value.apiPassword = '';
|
||||
dnsConfig.value.apiSecret = '';
|
||||
@@ -87,8 +79,14 @@ function resetFields() {
|
||||
}
|
||||
|
||||
function onProviderChange(p) {
|
||||
setDefaultTlsProvider(p);
|
||||
resetFields(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';
|
||||
}
|
||||
}
|
||||
|
||||
const gcdnsFileParseError = ref('');
|
||||
@@ -130,13 +128,23 @@ 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 @select="onProviderChange"/>
|
||||
</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>
|
||||
|
||||
<!-- powerdns -->
|
||||
<FormGroup v-if="provider === 'powerdns'">
|
||||
<label for="powerdnsApiUrlInput">{{ $t('domains.domainDialog.powerdnsApiUrl') }}</label>
|
||||
<TextInput id="powerdnsApiUrlInput" type="url" v-model="dnsConfig.apiUrl" placeholder="https://ns1.example.com:8081" required />
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'powerdns'">
|
||||
<label for="powerdnsApiKeyInput">{{ $t('domains.domainDialog.powerdnsApiKey') }}</label>
|
||||
<MaskedInput id="powerdnsApiKeyInput" v-model="dnsConfig.apiKey" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Route53 -->
|
||||
<FormGroup v-if="provider === 'route53'">
|
||||
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
|
||||
@@ -322,8 +330,8 @@ function onGcdnsFileInputChange(event) {
|
||||
<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/domains#certificates" 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>
|
||||
|
||||
140
dashboard/src/components/EnableTwoFADialog.vue
Normal file
140
dashboard/src/components/EnableTwoFADialog.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardAction, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const props = defineProps({
|
||||
mandatory2FA: { type: Boolean, default: false },
|
||||
has2FA: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const setupMode = ref('');
|
||||
const totpSecret = ref('');
|
||||
const totpToken = ref('');
|
||||
const totpQRCode = ref('');
|
||||
const totpEnableError = ref('');
|
||||
const passkeyRegisterError = ref('');
|
||||
const passkeyRegisterBusy = ref(false);
|
||||
|
||||
async function onTotpEnable() {
|
||||
const [error] = await profileModel.enableTotp(totpToken.value);
|
||||
if (error) {
|
||||
totpToken.value = '';
|
||||
return totpEnableError.value = error.body ? error.body.message : 'Internal error';
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
async function onRegisterPasskey() {
|
||||
passkeyRegisterBusy.value = true;
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
try {
|
||||
const [optionsError, options] = await profileModel.getPasskeyRegistrationOptions();
|
||||
if (optionsError) {
|
||||
passkeyRegisterError.value = optionsError.body?.message || 'Failed to get registration options';
|
||||
passkeyRegisterBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const [registerError] = await profileModel.registerPasskey(credential, 'Cloudron');
|
||||
if (registerError) {
|
||||
passkeyRegisterError.value = registerError.body?.message || 'Failed to register passkey';
|
||||
passkeyRegisterBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
} catch (error) {
|
||||
passkeyRegisterError.value = error.message || 'Passkey registration failed';
|
||||
}
|
||||
|
||||
passkeyRegisterBusy.value = false;
|
||||
}
|
||||
|
||||
async function loadTotpSecret() {
|
||||
const [error, result] = await profileModel.setTotpSecret();
|
||||
if (error) return console.error(error);
|
||||
|
||||
totpSecret.value = result.secret;
|
||||
totpQRCode.value = result.qrcode;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(method) {
|
||||
setupMode.value = method || 'passkey';
|
||||
totpEnableError.value = '';
|
||||
totpToken.value = '';
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
if (setupMode.value === 'totp') await loadTotpSecret();
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function switchMode(mode) {
|
||||
setupMode.value = mode;
|
||||
if (mode === 'totp' && !totpSecret.value) await loadTotpSecret();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog" :title="setupMode === 'totp' ? $t('profile.enableTotp.title') : $t('profile.enablePasskey.title')" :dismissable="!props.mandatory2FA || props.has2FA">
|
||||
<div>
|
||||
<p class="text-warning" v-if="props.mandatory2FA && !props.has2FA">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
|
||||
|
||||
<!-- Passkey Setup -->
|
||||
<div v-if="setupMode === 'passkey'">
|
||||
<p v-html="$t('profile.enable2FA.passkeyDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<div style="text-align: center;">
|
||||
<Button @click="onRegisterPasskey()" :loading="passkeyRegisterBusy" :disabled="passkeyRegisterBusy">{{ $t('profile.enable2FA.registerPasskey') }}</Button>
|
||||
<div class="error-label" v-if="passkeyRegisterError">{{ passkeyRegisterError }}</div>
|
||||
</div>
|
||||
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
|
||||
<a href="#" @click.prevent="switchMode('totp')">{{ $t('profile.enable2FA.switchToTotp') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Setup -->
|
||||
<div v-if="setupMode === 'totp'">
|
||||
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<div style="text-align: center;">
|
||||
<img :src="totpQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
|
||||
<small>{{ totpSecret }} <ClipboardAction plain :value="totpSecret"/></small>
|
||||
</div>
|
||||
<br/>
|
||||
<form @submit.prevent="onTotpEnable()">
|
||||
<input type="submit" style="display: none;" :disabled="!totpToken"/>
|
||||
<FormGroup style="text-align: left;">
|
||||
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="totpToken" id="totpTokenInput" style="flex-grow: 1;"/>
|
||||
<Button @click="onTotpEnable()" :disabled="!totpToken">{{ $t('profile.enable2FA.enable') }}</Button>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="totpEnableError">{{ totpEnableError }}</div>
|
||||
</FormGroup>
|
||||
</form>
|
||||
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
|
||||
<a href="#" @click.prevent="switchMode('passkey')">{{ $t('profile.enable2FA.switchToPasskey') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
280
dashboard/src/components/EventlogList.vue
Normal file
280
dashboard/src/components/EventlogList.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, onMounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
|
||||
import { useDebouncedRef, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { eventlogDetails, eventlogSource } from '../utils.js';
|
||||
|
||||
const props = defineProps({
|
||||
fetchPage: { type: Function, required: true },
|
||||
availableActions: { type: Array, default: () => [] },
|
||||
app: { type: Object, default: null },
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const apps = ref([]);
|
||||
const eventlogs = ref([]);
|
||||
const refreshBusy = ref(false);
|
||||
const page = ref(1);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
const actions = ref([]);
|
||||
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const currentMatchPosition = ref(-1);
|
||||
const searching = ref(false);
|
||||
const SEARCH_LOOKAHEAD_PAGES = 5;
|
||||
|
||||
const filterFrom = ref('');
|
||||
const filterTo = ref('');
|
||||
const dateFilterPopover = useTemplateRef('dateFilterPopover');
|
||||
const dateFilterButton = useTemplateRef('dateFilterButton');
|
||||
|
||||
function getApp(id) {
|
||||
return apps.value.find(a => a.id === id);
|
||||
}
|
||||
|
||||
function processEvent(e) {
|
||||
const app = props.app || (e.data?.appId ? getApp(e.data.appId) : null);
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, app, props.app?.id || ''),
|
||||
source: eventlogSource(e, app),
|
||||
};
|
||||
}
|
||||
|
||||
function isMatch(eventlog, term) {
|
||||
if (!term) return false;
|
||||
const t = term.toLowerCase();
|
||||
if (eventlog.source.toLowerCase().includes(t)) return true;
|
||||
if (eventlog.details.replace(/<[^>]+>/g, '').toLowerCase().includes(t)) return true;
|
||||
if (JSON.stringify(eventlog.raw.data).toLowerCase().includes(t)) return true;
|
||||
if (eventlog.raw.action.toLowerCase().includes(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchIndices = computed(() => {
|
||||
if (!highlight.value) return [];
|
||||
return eventlogs.value.reduce((acc, e, i) => {
|
||||
if (isMatch(e, highlight.value)) acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
function scrollToIndex(idx) {
|
||||
const el = eventlogContainer.value?.querySelector(`[data-index="${idx}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
}
|
||||
|
||||
function goToPrevMatch() {
|
||||
if (currentMatchPosition.value > 0) {
|
||||
currentMatchPosition.value--;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
}
|
||||
}
|
||||
|
||||
async function goToNextMatch() {
|
||||
if (!highlight.value || searching.value) return;
|
||||
|
||||
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
|
||||
currentMatchPosition.value++;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
let endOfLog = false;
|
||||
for (let i = 0; i < SEARCH_LOOKAHEAD_PAGES; i++) {
|
||||
const prevLength = eventlogs.value.length;
|
||||
await fetchMore();
|
||||
if (eventlogs.value.length === prevLength) { endOfLog = true; break; }
|
||||
|
||||
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
|
||||
currentMatchPosition.value++;
|
||||
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
searching.value = false;
|
||||
if (endOfLog) window.pankow.notify({ text: `No more matches for "${highlight.value}".`, timeout: 3000 });
|
||||
else window.pankow.notify({ text: `No match found for "${highlight.value}" in ${eventlogs.value.length} entries. Click next to keep searching.`, timeout: 3000 });
|
||||
}
|
||||
|
||||
function buildFilter() {
|
||||
const filter = {};
|
||||
if (actions.value.length) filter.actions = actions.value.join(',');
|
||||
if (filterFrom.value) filter.from = new Date(filterFrom.value + 'T00:00:00').toISOString();
|
||||
if (filterTo.value) filter.to = new Date(filterTo.value + 'T23:59:59.999').toISOString();
|
||||
return filter;
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
highlight.value = '';
|
||||
refreshBusy.value = true;
|
||||
page.value = 1;
|
||||
|
||||
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(processEvent);
|
||||
refreshBusy.value = false;
|
||||
}
|
||||
|
||||
async function fetchMore() {
|
||||
page.value++;
|
||||
|
||||
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = eventlogs.value.concat(result.map(processEvent));
|
||||
}
|
||||
|
||||
async function onScroll(event) {
|
||||
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
|
||||
}
|
||||
|
||||
function onOpenDateFilter(event) {
|
||||
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
|
||||
}
|
||||
|
||||
watch(actions.value, onRefresh);
|
||||
watch(filterFrom, onRefresh);
|
||||
watch(filterTo, onRefresh);
|
||||
watch(highlight, async () => {
|
||||
if (matchIndices.value.length > 0) {
|
||||
currentMatchPosition.value = 0;
|
||||
await nextTick();
|
||||
scrollToIndex(matchIndices.value[0]);
|
||||
} else {
|
||||
currentMatchPosition.value = -1;
|
||||
if (highlight.value) goToNextMatch();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.app) {
|
||||
const [error, result] = await appsModel.list();
|
||||
if (error) console.error(error);
|
||||
else apps.value = result;
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
|
||||
while (eventlogContainer.value && eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
|
||||
await fetchMore();
|
||||
}
|
||||
});
|
||||
|
||||
function setHighlight(value) { highlight.value = value; }
|
||||
|
||||
defineExpose({ refresh: onRefresh, setHighlight });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow: hidden; display: flex; flex-direction: column; height: 100%;">
|
||||
<div v-if="showToolbar" style="display: flex; align-items: center; gap: 5px; flex-wrap: wrap; padding-bottom: 10px; justify-content: flex-end;">
|
||||
<TextInput placeholder="Highlight..." v-model="highlight" @keydown.enter="goToNextMatch()"/>
|
||||
<Button tool plain :disabled="!highlight || currentMatchPosition <= 0 || searching" @click="goToPrevMatch()" icon="fa-solid fa-chevron-up" />
|
||||
<Button tool plain :disabled="!highlight || searching" :loading="searching" @click="goToNextMatch()" icon="fa-solid fa-chevron-down" />
|
||||
<Button tool secondary ref="dateFilterButton" @click="onOpenDateFilter($event)" :icon="(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" />
|
||||
<MultiSelect v-if="availableActions.length" :search-threshold="10" v-model="actions" :options="availableActions" option-label="label" option-key="id" :selected-label="actions.length ? $t('main.multiselect.selected', { n: actions.length }) : $t('eventlog.filterAllEvents')"/>
|
||||
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
|
||||
</div>
|
||||
<Popover ref="dateFilterPopover" width="300px">
|
||||
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px;">
|
||||
<FormGroup>
|
||||
<label>From</label>
|
||||
<DateTimeInput date-only v-model="filterFrom" :max="filterTo || undefined" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>To</label>
|
||||
<DateTimeInput date-only v-model="filterTo" :min="filterFrom || undefined" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Popover>
|
||||
<div ref="eventlogContainer" class="section-body" style="overflow-y: auto; overflow-x: hidden; flex: 1;" @scroll="onScroll">
|
||||
<table class="eventlog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 190px;">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 100px;">{{ $t('eventlog.source') }}</th>
|
||||
<th>{{ $t('eventlog.details') }}</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(eventlog, index) in eventlogs" :key="eventlog.id">
|
||||
<tr :data-index="index" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" @click="eventlog.isOpen = !eventlog.isOpen">
|
||||
<td>{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td class="eventlog-source">{{ eventlog.source }}</td>
|
||||
<td v-html="eventlog.details"></td>
|
||||
<td><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="app ? `/logs.html?appId=${app.id}&taskId=${eventlog.raw.data.taskId}` : `/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="4" class="eventlog-details" @click.stop>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-source {
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match-current {
|
||||
background-color: rgba(255, 193, 7, 0.35);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { fetcher } from '@cloudron/pankow';
|
||||
import OfflineOverlay from '../components/OfflineOverlay.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
@@ -9,6 +10,7 @@ const profileModel = ProfileModel.create();
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
const ready = ref(false);
|
||||
const serviceDown = ref(false);
|
||||
|
||||
function onOnline() {
|
||||
ready.value = true;
|
||||
@@ -24,6 +26,9 @@ fetcher.globalOptions.errorHook = (error) => {
|
||||
// re-login will make the code get a new token
|
||||
if (error.status === 401) return profileModel.logout();
|
||||
|
||||
// if sftp addon is down, tell the user
|
||||
if (error.status === 424) return serviceDown.value = true;
|
||||
|
||||
if (error.status === 500 || error.status === 501) {
|
||||
// actual internal server error, most likely a bug or timeout log to console only to not alert the user
|
||||
if (!ready.value) {
|
||||
@@ -48,6 +53,23 @@ onMounted(() => {
|
||||
<template>
|
||||
<div style="height: 100%;">
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()"/>
|
||||
<router-view v-if="ready"></router-view>
|
||||
<div v-if="ready && serviceDown" class="service-down">
|
||||
<div>
|
||||
Unable to connect to filemanager service. Check the status and logs in <a href="/#/services">Services view</a>.
|
||||
</div>
|
||||
</div>
|
||||
<router-view v-if="ready" v-show="!serviceDown"></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.service-down {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -4,9 +4,9 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, computed, useTemplateRef } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router';
|
||||
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
|
||||
import { fetcher, Dialog, DirectoryView, TreeView, TopBar, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
|
||||
import { sanitize, sleep } from '@cloudron/pankow/utils';
|
||||
import { API_ORIGIN, BASE_URL, ISTATES } from '../constants.js';
|
||||
import PreviewPanel from '../components/PreviewPanel.vue';
|
||||
@@ -33,7 +33,6 @@ const extractInProgressDialog = useTemplateRef('extractInProgressDialog');
|
||||
const busy = ref(true);
|
||||
const fallbackIcon = ref(`${BASE_URL}mime-types/none.svg`);
|
||||
const cwd = ref('/');
|
||||
const busyRefresh = ref(false);
|
||||
const busyRestart = ref(false);
|
||||
const fatalError = ref(false);
|
||||
const activeItem = ref(null);
|
||||
@@ -68,32 +67,6 @@ const uploadMenuModel = [{
|
||||
action: onUploadFolder,
|
||||
}];
|
||||
|
||||
const breadcrumbHomeItem = {
|
||||
label: '/app/data/',
|
||||
action: () => onActivateBreadcrumb('/'),
|
||||
};
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (!cwd.value) return [];
|
||||
|
||||
const parts = cwd.value.split('/').filter((p) => !!p.trim());
|
||||
const crumbs = [];
|
||||
|
||||
parts.forEach((p, i) => {
|
||||
crumbs.push({
|
||||
label: p,
|
||||
action: () => onActivateBreadcrumb('/' + parts.slice(0, i+1).join('/')),
|
||||
});
|
||||
});
|
||||
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
// watch(() => {
|
||||
// if (resourceType.value && resourceId.value) router.push(`/home/${resourceType.value}/${resourceId.value}${cwd.value}`);
|
||||
// loadCwd();
|
||||
// });
|
||||
|
||||
function onFatalError(errorMessage) {
|
||||
fatalError.value = errorMessage;
|
||||
fatalErrorDialog.value.open();
|
||||
@@ -155,14 +128,39 @@ function onSelectionChanged(items) {
|
||||
selectedItems.value = items;
|
||||
}
|
||||
|
||||
function onActivateBreadcrumb(path) {
|
||||
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(path)}`);
|
||||
function onTreeNavigate(event) {
|
||||
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(event.path)}`);
|
||||
}
|
||||
|
||||
async function onTreeDrop(targetPath, event) {
|
||||
// check if this is an internal pankow drag (files from DirectoryView)
|
||||
if (event.dataTransfer.getData('application/x-pankow') === 'selected') {
|
||||
const files = selectedItems.value;
|
||||
if (!files || !files.length) return;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
pasteInProgressDialog.value.open();
|
||||
|
||||
try {
|
||||
await directoryModel.paste(targetPath, 'cut', files);
|
||||
} catch (e) {
|
||||
window.pankow.notify({ type: 'danger', text: e, persistent: true });
|
||||
}
|
||||
|
||||
await loadCwd();
|
||||
|
||||
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
pasteInProgressDialog.value.close();
|
||||
}
|
||||
}
|
||||
|
||||
function treeListFiles(path) {
|
||||
if (!directoryModel) return [];
|
||||
return directoryModel.listFiles(path);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
busyRefresh.value = true;
|
||||
await loadCwd();
|
||||
setTimeout(() => { busyRefresh.value = false; }, 500);
|
||||
}
|
||||
|
||||
// either dataTransfer (external drop) or files (internal drag)
|
||||
@@ -177,37 +175,66 @@ async function onDrop(targetFolder, dataTransfer, files) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readEntries(dirReader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
function setRelativePath(file, entry) {
|
||||
const relativePath = (entry.fullPath || entry.name || '').replace(/^\//, '');
|
||||
if (relativePath) {
|
||||
// trying with defineProperty() to better mimic native behavior adding a non-enumeratible property
|
||||
try {
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath });
|
||||
} catch {
|
||||
file.webkitRelativePath = relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// wrapper as chrome only returns files in batches of 100 entries
|
||||
async function readAllEntries(dirReader) {
|
||||
const all = [];
|
||||
let batch;
|
||||
do {
|
||||
batch = await new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
all.push(...batch);
|
||||
} while (batch.length > 0);
|
||||
return all;
|
||||
}
|
||||
|
||||
const fileList = [];
|
||||
async function traverseFileTree(item) {
|
||||
if (item.isFile) {
|
||||
fileList.push(await getFile(item));
|
||||
const file = await getFile(item);
|
||||
setRelativePath(file, item);
|
||||
fileList.push(file);
|
||||
} else if (item.isDirectory) {
|
||||
// Get folder contents
|
||||
const dirReader = item.createReader();
|
||||
const entries = await readEntries(dirReader);
|
||||
const entries = await readAllEntries(dirReader);
|
||||
|
||||
for (const i in entries) {
|
||||
await traverseFileTree(entries[i], item.name);
|
||||
await traverseFileTree(entries[i]);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping uknown file type', item);
|
||||
console.log('Skipping unknown file type', item);
|
||||
}
|
||||
}
|
||||
|
||||
// collect all files to upload
|
||||
for (const item of dataTransfer.items) {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
|
||||
if (!entry) {
|
||||
const file = item.getAsFile ? item.getAsFile() : null;
|
||||
if (file) fileList.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile) {
|
||||
fileList.push(await getFile(entry));
|
||||
const file = await getFile(entry);
|
||||
setRelativePath(file, entry);
|
||||
fileList.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
await traverseFileTree(entry, sanitize(`${cwd.value}/${targetFolder}`));
|
||||
await traverseFileTree(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,8 +536,8 @@ onMounted(async () => {
|
||||
<template #header>
|
||||
<TopBar class="navbar">
|
||||
<template #left>
|
||||
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool style="margin-right: 10px"/>
|
||||
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
|
||||
<a v-if="appLink" class="title" :href="appLink" target="_blank">{{ title }}</a>
|
||||
<span v-else class="title">{{ title }}</span>
|
||||
</template>
|
||||
<template #right>
|
||||
<ButtonGroup>
|
||||
@@ -521,7 +548,7 @@ onMounted(async () => {
|
||||
<Button style="margin: 0 20px;" v-tooltip="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
|
||||
<Button :href="'/terminal.html?id=' + resourceId + '&cwd=/app/data' + cwd" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
|
||||
<Button :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" v-tooltip="$t('logs.title')" />
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
@@ -529,6 +556,17 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="main-view">
|
||||
<div class="main-view-col tree-view-col">
|
||||
<TreeView
|
||||
v-if="!busy"
|
||||
:list-files="treeListFiles"
|
||||
:active-path="cwd"
|
||||
:fallback-icon="fallbackIcon"
|
||||
root-label="/app/data"
|
||||
:drop-handler="onTreeDrop"
|
||||
@navigate="onTreeNavigate"
|
||||
/>
|
||||
</div>
|
||||
<div class="main-view-col">
|
||||
<DirectoryView
|
||||
class="directory-view"
|
||||
@@ -548,6 +586,7 @@ onMounted(async () => {
|
||||
:new-folder-handler="onNewFolder"
|
||||
:upload-file-handler="onUploadFile"
|
||||
:upload-folder-handler="onUploadFolder"
|
||||
:refresh-handler="onRefresh"
|
||||
:drop-handler="onDrop"
|
||||
:items="items"
|
||||
:owners-model="ownersModel"
|
||||
@@ -556,10 +595,6 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
<div class="main-view-col" style="max-width: 300px;">
|
||||
<div class="side-bar-title">
|
||||
<a v-show="appLink" :href="appLink" target="_blank" class="no-highlight">{{ title }}</a>
|
||||
<span v-show="!appLink">{{ title }}</span>
|
||||
</div>
|
||||
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,17 +621,20 @@ onMounted(async () => {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.side-bar-title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.main-view-col {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.no-highlight {
|
||||
.tree-view-col {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
border-right: 1px solid var(--pankow-input-border-color);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
color: var(--pankow-color-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ defineExpose({
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,14 @@ const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { eachLimit } from 'async';
|
||||
import { Menu, Button, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import NotificationsModel from '../models/NotificationsModel.js';
|
||||
import { Menu, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import ServicesModel from '../models/ServicesModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
defineProps(['config', 'subscription']);
|
||||
defineProps(['config', 'notificationCount']);
|
||||
|
||||
const profile = inject('profile');
|
||||
const subscription = inject('subscription');
|
||||
|
||||
const helpButton = useTemplateRef('helpButton');
|
||||
const helpPopover = useTemplateRef('helpPopover');
|
||||
@@ -27,58 +25,6 @@ function onOpenHelp(popover, event, elem) {
|
||||
const servicesModel = ServicesModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const notificationButton = useTemplateRef('notificationButton');
|
||||
const notificationPopover = useTemplateRef('notificationPopover');
|
||||
const notifications = ref([]);
|
||||
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) {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
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, 5, async (notification) => {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
if (error) return console.error(error);
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
notificationsAllBusy.value = false;
|
||||
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [error, result] = await notificationModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
result.forEach(n => {
|
||||
n.isCollapsed = true;
|
||||
n.busy = false;
|
||||
});
|
||||
|
||||
notifications.value = result;
|
||||
}
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
|
||||
function onSubscriptionRequired() {
|
||||
@@ -134,8 +80,6 @@ function onAvatarClick(event) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profile.value.isAtLeastAdmin) await refresh();
|
||||
|
||||
await trackPlatformStatus();
|
||||
});
|
||||
|
||||
@@ -150,30 +94,6 @@ onUnmounted(() => {
|
||||
<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">
|
||||
<div class="notification-item" v-for="notification in notifications" :key="notification.id">
|
||||
<div class="notification-item-title" @click="notification.isCollapsed = !notification.isCollapsed">
|
||||
<div>
|
||||
{{ notification.title }}<br/>
|
||||
<span class="notification-item-date" v-tooltip="prettyLongDate(notification.creationTime)">{{ prettyDate(notification.creationTime) }}</span>
|
||||
</div>
|
||||
<Button plain small tool :loading="notification.busy" :disabled="notification.busy" class="notification-read-button" @click.stop="onMarkNotificationRead(notification)">{{ $t('notifications.dismissTooltip') }}</Button>
|
||||
</div>
|
||||
<div class="notification-item-body" v-if="!notification.isCollapsed">
|
||||
<pre v-if="notification.messageJson" style="cursor: auto">{{ JSON.stringify(notification.messageJson, null, 4) }}</pre>
|
||||
<div v-else style="cursor: auto; overflow: auto;" v-html="marked.parse(notification.message)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="notifications.length" @click="onMarkAllNotificationRead()" :loading="notificationsAllBusy" :disabled="notificationsAllBusy">{{ $t('notifications.markAllAsRead') }}</Button>
|
||||
<div v-if="notifications.length === 0" class="notification-item-empty-placeholder">
|
||||
{{ $t('notifications.allCaughtUp') }}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<h1 class="help-title">{{ $t('support.help.title') }}</h1>
|
||||
@@ -196,10 +116,11 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<!-- Warnings if subscription is expired, unpaid or canceled -->
|
||||
<a v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" href="/#/cloudron-account">Subscription Expired</a>
|
||||
<a v-else-if="profile.isAtLeastOwner && (subscription.cancel_at || subscription.status === 'canceled')" class="headerbar-action subscription-canceled" href="/#/cloudron-account">Subscription Canceled</a>
|
||||
|
||||
<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>
|
||||
<a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="/#/notifications"><Icon :icon="notificationCount > 0 ? 'fas fa-bell' : 'far fa-bell'"/> {{ notificationCount > 99 ? '99+' : notificationCount }}</a>
|
||||
<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" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||
@@ -249,47 +170,16 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notification-item-date {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.notification-read-button {
|
||||
visibility: hidden;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-read-button {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.notification-item .notification-read-button {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.subscription-expired {
|
||||
.subscription-expired,
|
||||
.subscription-canceled {
|
||||
background-color: var(--pankow-color-danger);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.subscription-expired:hover {
|
||||
.subscription-expired:hover,
|
||||
.subscription-canceled:hover {
|
||||
color: white;
|
||||
background-color: var(--pankow-color-danger-hover);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, ClipboardButton, FormGroup, Button, InputGroup } from '@cloudron/pankow';
|
||||
import { Dialog, TextInput, ClipboardButton, FormGroup, InputGroup } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -13,17 +13,16 @@ const password = ref('');
|
||||
const success = ref(false);
|
||||
const busy = ref(false);
|
||||
|
||||
// https://stackoverflow.com/questions/1497481/javascript-password-generator
|
||||
function onGeneratePassword() {
|
||||
const length = 12;
|
||||
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let tmp = '';
|
||||
|
||||
for (var i = 0, n = charset.length; i < length; ++i) {
|
||||
tmp += charset.charAt(Math.floor(Math.random() * n));
|
||||
function generatePassword() {
|
||||
const blocks = [];
|
||||
const values = new Uint8Array(16);
|
||||
crypto.getRandomValues(values);
|
||||
for (let b = 0; b < 4; b++) {
|
||||
let block = '';
|
||||
for (let i = 0; i < 4; i++) block += String.fromCharCode(97 + (values[b * 4 + i] % 26));
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
password.value = tmp;
|
||||
return blocks.join('-');
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -45,7 +44,7 @@ defineExpose({
|
||||
u = JSON.parse(JSON.stringify(u)); // make a copy
|
||||
user.value = u;
|
||||
success.value = false;
|
||||
password.value = '';
|
||||
password.value = generatePassword();
|
||||
formError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
@@ -71,9 +70,8 @@ defineExpose({
|
||||
<FormGroup>
|
||||
<label for="passwordInput">{{ $t('users.setGhostDialog.password') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="passwordInput" v-model="password" style="flex-grow: 1;"/>
|
||||
<ClipboardButton v-if="success" :value="password" />
|
||||
<Button tool v-else @click="onGeneratePassword()" v-tooltip="$t('users.setGhostDialog.generatePassword')" icon="fa fa-key" />
|
||||
<TextInput id="passwordInput" v-model="password" readonly style="flex-grow: 1;"/>
|
||||
<ClipboardButton :value="password" />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -116,7 +116,7 @@ onMounted(async () => {
|
||||
<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>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#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" required/>
|
||||
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -116,7 +116,7 @@ onMounted(async () => {
|
||||
<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>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#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" required/>
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -62,7 +62,11 @@ onMounted(async () => {
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (appId) {
|
||||
if (appId && taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (appId) {
|
||||
type.value = 'app';
|
||||
id.value = appId;
|
||||
name.value = 'App ' + appId;
|
||||
@@ -89,7 +93,7 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value);
|
||||
logsModel = LogsModel.create(type.value, id.value, { appId });
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
@@ -205,7 +209,7 @@ body {
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button } from '@cloudron/pankow';
|
||||
import { Button, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import MailModel from '../models/MailModel.js';
|
||||
|
||||
@@ -103,11 +103,11 @@ onMounted(async () => {
|
||||
<div v-if="key === 'mx' && domain.provider === 'namecheap'">{{ $t('email.dnsStatus.namecheapInfo') }} <sup><a href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div v-if="key === 'ptr4' || key === 'ptr6'">{{ $t('email.dnsStatus.ptrInfo') }} <sup><a href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div v-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
<table class="domain-status">
|
||||
<div v-else style="overflow: hidden;">
|
||||
<table class="domain-status" style="width: 100%; table-layout: fixed;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.hostname') }}:</td>
|
||||
<td style="width: 160px">{{ $t('email.dnsStatus.hostname') }}:</td>
|
||||
<td>{{ domainStatus[key].name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -119,12 +119,17 @@ onMounted(async () => {
|
||||
<td>{{ domainStatus[key].type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.expected') }}:</td>
|
||||
<td>{{ domainStatus[key].expected }}</td>
|
||||
<td class="domain-status-expected-label">{{ $t('email.dnsStatus.expected') }}:</td>
|
||||
<td class="domain-status-expected-value">
|
||||
<div class="domain-status-expected">{{ domainStatus[key].expected }}</div>
|
||||
<ClipboardAction :value="domainStatus[key].expected"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.current') }}:</td>
|
||||
<td>{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</td>
|
||||
<td>
|
||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -219,7 +224,7 @@ onMounted(async () => {
|
||||
overflow: scroll;
|
||||
white-space: nowrap;
|
||||
text-overflow: auto;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.domain-status > tbody > tr > td:first-of-type {
|
||||
@@ -227,4 +232,19 @@ onMounted(async () => {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.domain-status-expected-label {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.domain-status-expected-value {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.domain-status-expected {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Switch } from '@cloudron/pankow';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Switch, Dialog } from '@cloudron/pankow';
|
||||
import SettingsItem from '../components/SettingsItem.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const dialogItem = useTemplateRef('dialogItem');
|
||||
const busy = ref(false);
|
||||
const appUpp = ref(false);
|
||||
const appDown = ref(false);
|
||||
const appOutOfMemory = ref(false);
|
||||
const backupFailed = ref(false);
|
||||
const appAutoUpdateFailed = ref(false);
|
||||
const certificateRenewalFailed = ref(false);
|
||||
const diskSpace = ref(false);
|
||||
const cloudronUpdateFailed = ref(false);
|
||||
const manualUpdateRequired = ref(false);
|
||||
const reboot = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -26,18 +28,22 @@ async function onSubmit() {
|
||||
if (appDown.value) config.push('appDown');
|
||||
if (appOutOfMemory.value) config.push('appOutOfMemory');
|
||||
if (backupFailed.value) config.push('backupFailed');
|
||||
if (appAutoUpdateFailed.value) config.push('appAutoUpdateFailed');
|
||||
if (certificateRenewalFailed.value) config.push('certificateRenewalFailed');
|
||||
if (diskSpace.value) config.push('diskSpace');
|
||||
if (cloudronUpdateFailed.value) config.push('cloudronUpdateFailed');
|
||||
if (manualUpdateRequired.value) config.push('manualUpdateRequired');
|
||||
if (reboot.value) config.push('reboot');
|
||||
|
||||
const [error] = await profileModel.setNotificationConfig(config);
|
||||
if (error) return console.error(error);
|
||||
|
||||
dialogItem.value.close();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function open() {
|
||||
const [error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
|
||||
@@ -47,58 +53,85 @@ onMounted(async () => {
|
||||
appDown.value = config.indexOf('appDown') !== -1;
|
||||
appOutOfMemory.value = config.indexOf('appOutOfMemory') !== -1;
|
||||
backupFailed.value = config.indexOf('backupFailed') !== -1;
|
||||
appAutoUpdateFailed.value = config.indexOf('appAutoUpdateFailed') !== -1;
|
||||
certificateRenewalFailed.value = config.indexOf('certificateRenewalFailed') !== -1;
|
||||
diskSpace.value = config.indexOf('diskSpace') !== -1;
|
||||
cloudronUpdateFailed.value = config.indexOf('cloudronUpdateFailed') !== -1;
|
||||
manualUpdateRequired.value = config.indexOf('manualUpdateRequired') !== -1;
|
||||
reboot.value = config.indexOf('reboot') !== -1;
|
||||
|
||||
dialogItem.value.open();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section :title="$t('notifications.settings.title')">
|
||||
<Dialog ref="dialogItem"
|
||||
:title="$t('notifications.settings.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
confirm-style="primary"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
|
||||
<div>{{ $t('notifications.settingsDialog.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appUp') }}</div>
|
||||
<Switch v-model="appUpp" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="appUpp" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appDown') }}</div>
|
||||
<Switch v-model="appDown" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="appDown" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appOutOfMemory') }}</div>
|
||||
<Switch v-model="appOutOfMemory" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="appOutOfMemory" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.backupFailed') }}</div>
|
||||
<Switch v-model="backupFailed" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="backupFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appAutoUpdateFailed') }}</div>
|
||||
<Switch v-model="appAutoUpdateFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.certificateRenewalFailed') }}</div>
|
||||
<Switch v-model="certificateRenewalFailed" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="certificateRenewalFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.diskSpace') }}</div>
|
||||
<Switch v-model="diskSpace" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="diskSpace" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.cloudronUpdateFailed') }}</div>
|
||||
<Switch v-model="cloudronUpdateFailed" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="cloudronUpdateFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.manualUpdateRequired') }}</div>
|
||||
<Switch v-model="manualUpdateRequired" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.rebootRequired') }}</div>
|
||||
<Switch v-model="reboot" :disabled="busy" @change="onSubmit()"/>
|
||||
<Switch v-model="reboot" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<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';
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Dialog } from '@cloudron/pankow';
|
||||
import { stripSsoInfo } from '../utils.js';
|
||||
import { renderSafeMarkdown, stripSsoInfo } from '../utils.js';
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const app = ref(null);
|
||||
@@ -48,13 +47,13 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="app.manifest.postInstallMessage" v-html="marked.parse(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
|
||||
<div v-if="app.manifest.postInstallMessage" v-html="renderSafeMarkdown(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
|
||||
|
||||
<div class="app-info-checklist" v-show="hasPendingChecklistItems">
|
||||
<label class="control-label">{{ $t('app.appInfo.checklist') }}</label>
|
||||
<div v-for="(item, key) in app.checklist" :key="key">
|
||||
<div class="checklist-item" v-show="!item.acknowledged">
|
||||
<span v-html="marked.parse(item.message)"></span>
|
||||
<span v-html="renderSafeMarkdown(item.message)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
77
dashboard/src/components/SaveIndicator.vue
Normal file
77
dashboard/src/components/SaveIndicator.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@cloudron/pankow';
|
||||
|
||||
const visible = ref(false);
|
||||
const success = ref(false);
|
||||
const TIMEOUT = 1500;
|
||||
let timeoutId;
|
||||
|
||||
defineExpose({
|
||||
success() {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
success.value = true;
|
||||
visible.value = true;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, TIMEOUT);
|
||||
},
|
||||
error() {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
success.value = false;
|
||||
visible.value = true;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, TIMEOUT);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="bounce">
|
||||
<div class="save-indicator" v-if="visible" :class="{ success: success, error: !success }"><Icon :icon="success ? 'fa-solid fa-check' : 'fa-solid fa-xmark'"/></div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.save-indicator {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.save-indicator.success {
|
||||
color: var(--pankow-color-success);
|
||||
}
|
||||
|
||||
.save-indicator.error {
|
||||
color: var(--pankow-color-danger);
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
animation: bounce-in 0.5s;
|
||||
}
|
||||
|
||||
.bounce-leave-active {
|
||||
animation: bounce-in 0.5s reverse;
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -79,7 +79,6 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header-title-text {
|
||||
@@ -106,6 +105,7 @@ onUnmounted(() => {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px 15px;
|
||||
padding-bottom: 25px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header-title-badge {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { marked } from 'marked';
|
||||
import { Button, PasswordInput, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
import { startAuthFlow } from '../utils.js';
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
@@ -46,7 +47,7 @@ function validateForm() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
if (!form.value.reportValidity() || !isFormValid.value) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -89,7 +90,7 @@ async function onSubmit() {
|
||||
// set token to autologin on first oidc flow
|
||||
localStorage.cloudronFirstTimeToken = result.accessToken;
|
||||
|
||||
dashboardUrl.value = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
dashboardUrl.value = await startAuthFlow('cid-webadmin', '');
|
||||
|
||||
busy.value = false;
|
||||
mode.value = MODE.DONE;
|
||||
|
||||
@@ -199,7 +199,7 @@ function onBackdrop(event) {
|
||||
.sidebar-item-header {
|
||||
background-color: #e9ecef;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
color: var(--pankow-text-color);
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
@@ -224,7 +224,7 @@ function onBackdrop(event) {
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
|
||||
@@ -7,14 +7,14 @@ defineProps({
|
||||
},
|
||||
state: {
|
||||
validator(value) {
|
||||
// The value must match one of these strings
|
||||
return ['success', 'warning', 'danger', ''].includes(value);
|
||||
return ['success', 'warning', 'danger', 'idle', ''].includes(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function color(state) {
|
||||
if (state === 'success') return '#27CE65';
|
||||
else if (state === 'idle') return '#BCD0C3';
|
||||
else if (state === 'warning') return '#f0ad4e';
|
||||
else if (state === 'danger') return '#d9534f';
|
||||
else return '#7c7c7c';
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog, Spinner } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
@@ -50,6 +50,12 @@ const columns = {
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
integrity: {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
@@ -66,6 +72,12 @@ function createActionMenu(backup) {
|
||||
icon: 'fa-solid fa-file-alt',
|
||||
label: t('backups.listing.tooltipDownloadBackupConfig'),
|
||||
action: onDownloadConfig.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-key',
|
||||
label: backup.integrityCheckTask?.active ? t('backups.stopIntegrity') : t('backups.checkIntegrity'),
|
||||
action: backup.integrityCheckTask?.active ? onStopIntegrityCheck.bind(null, backup) : onStartIntegrityCheck.bind(null, backup),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -151,6 +163,18 @@ async function refreshTasks() {
|
||||
});
|
||||
}
|
||||
|
||||
const INTEGRITY_POLL_INTERVAL_MS = 5000;
|
||||
let integrityPollTimer = null;
|
||||
|
||||
function scheduleIntegrityPoll() {
|
||||
if (integrityPollTimer) return;
|
||||
integrityPollTimer = setTimeout(async () => {
|
||||
integrityPollTimer = null;
|
||||
await refreshBackups();
|
||||
if (backups.value.some(b => b.integrityCheckTask?.active)) scheduleIntegrityPoll();
|
||||
}, INTEGRITY_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function refreshBackups() {
|
||||
const [error, result] = await backupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
@@ -161,6 +185,20 @@ async function refreshBackups() {
|
||||
});
|
||||
|
||||
backups.value = result;
|
||||
|
||||
if (result.some(b => b.integrityCheckTask?.active)) scheduleIntegrityPoll();
|
||||
}
|
||||
|
||||
async function onStartIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.startIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackups();
|
||||
}
|
||||
|
||||
async function onStopIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.stopIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackups();
|
||||
}
|
||||
|
||||
async function refreshBackupSites() {
|
||||
@@ -231,6 +269,10 @@ onMounted(async () => {
|
||||
await refreshTasks();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (integrityPollTimer) clearTimeout(integrityPollTimer);
|
||||
});
|
||||
|
||||
defineExpose({ refresh });
|
||||
|
||||
</script>
|
||||
@@ -272,7 +314,7 @@ defineExpose({ refresh });
|
||||
</template>
|
||||
|
||||
<TableView :columns="columns" :model="backups" :busy="busy" :placeholder="$t('backups.listing.noBackups')">
|
||||
<template #creationTime="backup">
|
||||
<template #creationTime="{ item:backup }">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
@@ -280,18 +322,26 @@ defineExpose({ refresh });
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content="backup">
|
||||
<template #content="{ item:backup }">
|
||||
<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">
|
||||
<template #size="{ item:backup }">
|
||||
<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 #site="{ item:backup }">{{ backup.site.name }}</template>
|
||||
|
||||
<template #actions="backup">
|
||||
<template #integrity="{ item:backup }">
|
||||
<Spinner v-if="backup.integrityCheckTask?.active" style="min-width: 0;"/>
|
||||
<div v-else-if="backup.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(backup.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ item:backup }">
|
||||
<ActionBar :actions="createActionMenu(backup)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -30,7 +30,8 @@ const taskLogsMenu = ref([]);
|
||||
const apps = ref([]);
|
||||
const version = ref('');
|
||||
const ubuntuVersion = ref('');
|
||||
const currentPattern = ref('');
|
||||
const currentSchedule = ref('');
|
||||
const currentPolicy = ref('');
|
||||
const updateBusy = ref(false);
|
||||
const updateError = ref({});
|
||||
const stopError = ref({});
|
||||
@@ -55,17 +56,16 @@ const inProgressApps = computed(() => {
|
||||
const configureDialog = useTemplateRef('configureDialog');
|
||||
const configureBusy = ref(false);
|
||||
const configureError = ref('');
|
||||
const configureType = ref('');
|
||||
const configurePattern = ref('');
|
||||
const configurePolicy = ref('');
|
||||
const configureDays = ref([]);
|
||||
const configureHours = ref([]);
|
||||
|
||||
async function refreshAutoupdatePattern() {
|
||||
const [error, result] = await updaterModel.getAutoupdatePattern();
|
||||
async function refreshAutoupdateConfig() {
|
||||
const [error, result] = await updaterModel.getAutoupdateConfig();
|
||||
if (error) return console.error(error);
|
||||
|
||||
currentPattern.value = result.pattern;
|
||||
configurePattern.value = result.pattern;
|
||||
currentSchedule.value = result.schedule;
|
||||
currentPolicy.value = result.policy;
|
||||
}
|
||||
|
||||
async function refreshApps() {
|
||||
@@ -87,21 +87,22 @@ async function refreshPendingUpdateInfo() {
|
||||
}
|
||||
|
||||
function onShowConfigure() {
|
||||
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
|
||||
configurePolicy.value = currentPolicy.value || 'never';
|
||||
|
||||
if (currentPolicy.value !== 'never') {
|
||||
const result = parseSchedule(currentSchedule.value);
|
||||
configureDays.value = result.days;
|
||||
configureHours.value = result.hours;
|
||||
}
|
||||
|
||||
configureDialog.value.open();
|
||||
}
|
||||
|
||||
async function onSubmitConfigure() {
|
||||
let pattern = 'never';
|
||||
if (configureType.value === 'pattern') {
|
||||
let schedule = currentSchedule.value || '00 00 1,3,5,23 * * *';
|
||||
const policy = configurePolicy.value;
|
||||
|
||||
if (policy !== 'never') {
|
||||
let daysPattern;
|
||||
if (configureDays.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = configureDays.value.join(',');
|
||||
@@ -110,18 +111,18 @@ async function onSubmitConfigure() {
|
||||
if (configureHours.value.length === 24) hoursPattern = '*';
|
||||
else hoursPattern = configureHours.value.join(',');
|
||||
|
||||
pattern ='00 00 ' + hoursPattern + ' * * ' + daysPattern;
|
||||
schedule = '00 00 ' + hoursPattern + ' * * ' + daysPattern;
|
||||
}
|
||||
|
||||
configureBusy.value = true;
|
||||
const [error] = await updaterModel.setAutoupdatePattern(pattern);
|
||||
const [error] = await updaterModel.setAutoupdateConfig(schedule, policy);
|
||||
if (error) {
|
||||
configureError.value = error.body ? error.body.message : 'Internal error';
|
||||
configureBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
|
||||
configureBusy.value = false;
|
||||
configureDialog.value.close();
|
||||
@@ -239,7 +240,7 @@ onMounted(async () => {
|
||||
ubuntuVersion.value = result.ubuntuVersion;
|
||||
|
||||
await refreshPendingUpdateInfo();
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
await refreshTasks();
|
||||
|
||||
ready.value = true;
|
||||
@@ -288,25 +289,35 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="configureDialog"
|
||||
:title="$t('settings.updateScheduleDialog.title')"
|
||||
:title="$t('settings.configureUpdates.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-active="configureType === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
|
||||
:confirm-active="configurePolicy === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
|
||||
:confirm-busy="configureBusy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!configureBusy"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitConfigure()"
|
||||
>
|
||||
<FormGroup>
|
||||
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
|
||||
|
||||
<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')"/>
|
||||
<label>{{ $t('settings.configureUpdates.policy') }}</label>
|
||||
<div>{{ $t('settings.configureUpdates.policyDescription') }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="configurePolicy" value="never" :label="$t('settings.updates.disabled')" />
|
||||
<Radiobutton v-model="configurePolicy" value="apps_only" :label="$t('settings.updates.appsOnly')" />
|
||||
<Radiobutton v-model="configurePolicy" value="platform_and_apps" :label="$t('settings.updates.platformAndApps')" />
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<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>
|
||||
<FormGroup>
|
||||
<div v-show="configurePolicy !== 'never'">
|
||||
<label>{{ $t('settings.configureUpdates.schedule') }}</label>
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-top: 12px">
|
||||
<div>{{ $t('settings.configureUpdates.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.configureUpdates.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="!(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Dialog>
|
||||
@@ -321,9 +332,10 @@ onMounted(async () => {
|
||||
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettySchedule(currentPattern) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.disabled') }}</span>
|
||||
<label>{{ $t('settings.updates.config') }}</label>
|
||||
<span v-if="currentPolicy === 'never'">{{ $t('settings.updates.disabled') }}</span>
|
||||
<span v-else-if="currentPolicy === 'apps_only'">{{ $t('settings.updates.appsOnly') }} - {{ prettySchedule(currentSchedule) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.platformAndApps') }} - {{ prettySchedule(currentSchedule) }}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button tool plain @click="onShowConfigure()">{{ $t('main.dialog.edit') }}</Button>
|
||||
|
||||
@@ -28,6 +28,7 @@ const showFilemanager = ref(false);
|
||||
const manifestVersion = ref('');
|
||||
const schedulerMenuModel = ref([]);
|
||||
const id = ref('');
|
||||
const cwd = ref('');
|
||||
const name = ref('');
|
||||
const link = ref('');
|
||||
const downloadFileDownloadUrl = ref('');
|
||||
@@ -165,7 +166,9 @@ async function connect(retry = false) {
|
||||
|
||||
let execId;
|
||||
try {
|
||||
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id.value}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: accessToken });
|
||||
const execBody = { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' };
|
||||
if (cwd.value) execBody.cwd = cwd.value;
|
||||
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id.value}/exec`, execBody, { access_token: accessToken });
|
||||
execId = result.body.id;
|
||||
} catch (error) {
|
||||
console.error('Cannot create socket.', error);
|
||||
@@ -216,6 +219,7 @@ onMounted(async () => {
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
id.value = urlParams.get('id');
|
||||
cwd.value = urlParams.get('cwd') || '';
|
||||
|
||||
if (!id.value) {
|
||||
console.error('No app id specified');
|
||||
|
||||
@@ -72,7 +72,7 @@ async function onSubmit() {
|
||||
let userId = user.value ? user.value.id : null;
|
||||
|
||||
// can only be set not updated
|
||||
if (!user.value || !user.value.username) data.username = username.value || null;
|
||||
if ((!user.value || !user.value.username) && username.value) data.username = username.value;
|
||||
|
||||
const isExternal = user.value && user.value.source;
|
||||
|
||||
@@ -241,15 +241,15 @@ defineExpose({
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<FormGroup :has-error="!!formError.email">
|
||||
<label for="emailInput">{{ $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>
|
||||
|
||||
@@ -15,6 +15,8 @@ const domain = ref('');
|
||||
const matrixHostname = ref('');
|
||||
const mastodonHostname = ref('');
|
||||
const jitsiHostname = ref('');
|
||||
const carddavLocation = ref('');
|
||||
const caldavLocation = ref('');
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
@@ -47,6 +49,9 @@ async function onSubmit() {
|
||||
+ '</XRD>';
|
||||
}
|
||||
|
||||
if (carddavLocation.value) wellKnown['carddav'] = carddavLocation.value;
|
||||
if (caldavLocation.value) wellKnown['caldav'] = caldavLocation.value;
|
||||
|
||||
const [error] = await domainsModel.setWellKnown(domain.value, wellKnown);
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
@@ -66,19 +71,21 @@ defineExpose({
|
||||
matrixHostname.value = '';
|
||||
mastodonHostname.value = '';
|
||||
jitsiHostname.value = '';
|
||||
caldavLocation.value = '';
|
||||
carddavLocation.value = '';
|
||||
|
||||
try {
|
||||
if (d.wellKnown && d.wellKnown['matrix/server']) {
|
||||
matrixHostname.value = JSON.parse(d.wellKnown['matrix/server'])['m.server'];
|
||||
}
|
||||
if (d.wellKnown && d.wellKnown['host-meta']) {
|
||||
mastodonHostname.value = d.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
|
||||
}
|
||||
if (d.wellKnown && d.wellKnown['matrix/client']) {
|
||||
const parsed = JSON.parse(d.wellKnown['matrix/client']);
|
||||
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
|
||||
jitsiHostname.value = parsed['im.vector.riot.jitsi']['preferredDomain'];
|
||||
if (d.wellKnown) {
|
||||
if (d.wellKnown['matrix/server']) matrixHostname.value = JSON.parse(d.wellKnown['matrix/server'])['m.server'];
|
||||
if (d.wellKnown['host-meta']) mastodonHostname.value = d.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
|
||||
if (d.wellKnown['matrix/client']) {
|
||||
const parsed = JSON.parse(d.wellKnown['matrix/client']);
|
||||
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
|
||||
jitsiHostname.value = parsed['im.vector.riot.jitsi']['preferredDomain'];
|
||||
}
|
||||
}
|
||||
if (d.wellKnown['carddav']) carddavLocation.value = d.wellKnown['carddav'];
|
||||
if (d.wellKnown['caldav']) caldavLocation.value = d.wellKnown['caldav'];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -110,6 +117,16 @@ defineExpose({
|
||||
|
||||
<p class="has-error" v-show="errorMessage">{{ errorMessage }}</p>
|
||||
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('domains.domainDialog.carddavLocation') }}</label>
|
||||
<TextInput id="" v-model="carddavLocation" placeholder="contacts.example.com"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('domains.domainDialog.caldavLocation') }}</label>
|
||||
<TextInput id="" v-model="caldavLocation" placeholder="calendar.example.com"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('domains.domainDialog.matrixHostname') }}</label>
|
||||
<TextInput id="" v-model="matrixHostname" placeholder="synapse.example.com:443"/>
|
||||
|
||||
@@ -56,6 +56,7 @@ onMounted(async () => {
|
||||
u.username = u.username || u.email; // ensure username
|
||||
userIds.add(u.id);
|
||||
}
|
||||
result.forEach(u => { u.label = u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -90,7 +91,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading">
|
||||
<div v-show="!loading">
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<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>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar } from '@cloudron/pankow';
|
||||
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar, Spinner } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { API_ORIGIN, RSTATES } from '../../constants.js';
|
||||
import { download } from '../../utils.js';
|
||||
@@ -14,14 +14,13 @@ import AppRestoreDialog from '../AppRestoreDialog.vue';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
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 BackupsModel from '../../models/BackupsModel.js';
|
||||
import BackupInfoDialog from '../BackupInfoDialog.vue';
|
||||
import ActionBar from '../../components/ActionBar.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const backupsModel = BackupsModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
@@ -47,12 +46,21 @@ const columns = ref({
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
},
|
||||
integrity: {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
},
|
||||
actions: {
|
||||
label: '',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
}
|
||||
});
|
||||
|
||||
const accessLevel = props.app.accessLevel;
|
||||
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-info',
|
||||
@@ -61,27 +69,27 @@ function createActionMenu(backup) {
|
||||
}, {
|
||||
icon: 'fa-solid fa-pencil-alt',
|
||||
label: t('main.action.edit'),
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
action: onEdit.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-download',
|
||||
label: t('app.backups.backups.downloadBackupTooltip'),
|
||||
visible: backup.site.format === 'tgz' && props.app.accessLevel === 'admin',
|
||||
visible: backup.site.format === 'tgz' && accessLevel === 'admin',
|
||||
href: getDownloadLink(backup),
|
||||
}, {
|
||||
icon: 'fa-solid fa-file-alt',
|
||||
label: t('app.backups.backups.downloadConfigTooltip'),
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
action: onDownloadConfig.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
}, {
|
||||
icon: 'fa-solid fa-clone',
|
||||
label: t('app.backups.backups.cloneTooltip'),
|
||||
visible: props.app.accessLevel === 'admin',
|
||||
visible: accessLevel === 'admin',
|
||||
action: onClone.bind(null, backup),
|
||||
}, {
|
||||
icon: 'fa-solid fa-history',
|
||||
@@ -89,13 +97,13 @@ function createActionMenu(backup) {
|
||||
disabled: !!props.app.taskId || props.app.runState === 'stopped',
|
||||
action: onRestore.bind(null, backup),
|
||||
quickAction: true
|
||||
// }, {
|
||||
// separator: true,
|
||||
// }, {
|
||||
// icon: 'fa-solid fa-key',
|
||||
// label: t('app.backups.backups.checkIntegrity'),
|
||||
// visible: props.app.accessLevel === 'admin',
|
||||
// action: onCheckIntegrity.bind(null, backup),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-key',
|
||||
label: backup.integrityCheckTask?.active ? t('backups.stopIntegrity') : t('backups.checkIntegrity'),
|
||||
visible: accessLevel === 'admin',
|
||||
action: backup.integrityCheckTask?.active ? onStopIntegrityCheck.bind(null, backup) : onStartIntegrityCheck.bind(null, backup),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -130,7 +138,7 @@ async function onChangeAutoBackups(value) {
|
||||
async function waitForTask() {
|
||||
if (!lastTask.value.id) return;
|
||||
|
||||
const [error, result] = await tasksModel.get(lastTask.value.id);
|
||||
const [error, result] = await appsModel.getAppTask(props.app.id, lastTask.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result;
|
||||
@@ -147,7 +155,7 @@ async function waitForTask() {
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_APP_BACKUP_PREFIX + props.app.id);
|
||||
const [error, result] = await appsModel.listTasks(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result[0] || {};
|
||||
@@ -157,7 +165,7 @@ async function refreshTasks() {
|
||||
return {
|
||||
icon: 'fa-solid ' + ((!t.active && t.success) ? 'status-active fa-check-circle' : (t.active ? 'fa-circle-notch fa-spin' : 'status-error fa-times-circle')),
|
||||
label: prettyLongDate(t.ts),
|
||||
action: () => { window.open(`/logs.html?taskId=${t.id}`); }
|
||||
action: () => { window.open(`/logs.html?appId=${props.app.id}&taskId=${t.id}`); }
|
||||
};
|
||||
});
|
||||
|
||||
@@ -177,7 +185,7 @@ async function onStartBackup(backupSiteId) {
|
||||
async function onStopBackup() {
|
||||
stopBackupBusy.value = true;
|
||||
|
||||
const [error] = await tasksModel.stop(lastTask.value.id);
|
||||
const [error] = await appsModel.stopAppTask(props.app.id, lastTask.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refreshTasks();
|
||||
@@ -196,6 +204,7 @@ function onEdit(backup) {
|
||||
editLabel.value = backup.label || '';
|
||||
editError.value = '';
|
||||
editDialog.value.open();
|
||||
setTimeout(() => document.getElementById('labelInput').focus(), 500);
|
||||
}
|
||||
|
||||
async function onEditSubmit() {
|
||||
@@ -232,11 +241,17 @@ async function onRestore(backup) {
|
||||
restoreDialog.value.open();
|
||||
}
|
||||
|
||||
// const backupsModel = BackupsModel.create();
|
||||
async function onStartIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.startIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackupList();
|
||||
}
|
||||
|
||||
// async function onCheckIntegrity(backup) {
|
||||
// await backupsModel.checkIntegrity(backup.id);
|
||||
// }
|
||||
async function onStopIntegrityCheck(backup) {
|
||||
const [error] = await backupsModel.stopIntegrityCheck(backup.id);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
await refreshBackupList();
|
||||
}
|
||||
|
||||
async function onRestoreSubmit() {
|
||||
restoreBusy.value = true;
|
||||
@@ -260,14 +275,32 @@ function onClone(backup) {
|
||||
cloneDialog.value.open(backup, props.app.id);
|
||||
}
|
||||
|
||||
const INTEGRITY_POLL_INTERVAL_MS = 5000;
|
||||
let integrityPollTimer = null;
|
||||
|
||||
function scheduleIntegrityPoll() {
|
||||
if (integrityPollTimer) return;
|
||||
integrityPollTimer = setTimeout(async () => {
|
||||
integrityPollTimer = null;
|
||||
await refreshBackupList();
|
||||
if (backups.value.some(b => b.integrityCheckTask?.active)) {
|
||||
scheduleIntegrityPoll();
|
||||
}
|
||||
}, INTEGRITY_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function refreshBackupList() {
|
||||
const [error, result] = await appsModel.backups(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
result.forEach(backup => {
|
||||
for (const backup of result) {
|
||||
backup.site = backupSites.value.find(t => t.id === backup.siteId);
|
||||
});
|
||||
}
|
||||
backups.value = result;
|
||||
|
||||
if (result.some(b => b.integrityCheckTask?.active)) {
|
||||
scheduleIntegrityPoll();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -290,6 +323,10 @@ onMounted(async () => {
|
||||
busy.value = false;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (integrityPollTimer) clearTimeout(integrityPollTimer);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -374,7 +411,7 @@ onMounted(async () => {
|
||||
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
|
||||
<div style="flex-grow: 1; overflow: hidden;">
|
||||
<ProgressBar :value="lastTask.percent" :show-label="false" :busy="true" :mode="lastTask.percent <= 0 ? 'indeterminate' : ''"/>
|
||||
<a :href="`/logs.html?taskId=${lastTask.id}`" target="_blank" style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ lastTask.percent }}% {{ lastTask.message }}</a>
|
||||
<a :href="`/logs.html?appId=${props.app.id}&taskId=${lastTask.id}`" target="_blank" style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ lastTask.percent }}% {{ lastTask.message }}</a>
|
||||
</div>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onStopBackup()" :loading="stopBackupBusy" :disabled="stopBackupBusy"></Button>
|
||||
</div>
|
||||
@@ -387,23 +424,28 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :model="backups" :columns="columns" :busy="busy" :placeholder="$t('backups.listing.noBackups')" style="max-height: 400px;" >
|
||||
<template #creationTime="backup">
|
||||
<template #creationTime="{ item }">
|
||||
<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>
|
||||
<span>{{ prettyLongDate(item.creationTime) }}</span>
|
||||
<span v-if="item.label"> <b>{{ item.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="item.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #site="backup">
|
||||
{{ backup.site.name }}
|
||||
<template #site="{ item }">
|
||||
{{ item.site.name }}
|
||||
</template>
|
||||
<template #size="backup">
|
||||
<span v-if="backup.stats?.upload">{{ prettyFileSize(backup.stats.upload.size) }} - {{ backup.stats.upload.fileCount }} file(s)</span>
|
||||
<template #size="{ item }">
|
||||
<span v-if="item.stats?.upload">{{ prettyFileSize(item.stats.upload.size) }} - {{ item.stats.upload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<ActionBar style="width: 100px" :actions="createActionMenu(backup)"/>
|
||||
<template #integrity="{ item }">
|
||||
<Spinner v-if="item.integrityCheckTask?.active" style="min-width: 0;"/>
|
||||
<div v-else-if="item.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': item.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': item.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': item.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(item.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<ActionBar :actions="createActionMenu(item)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button, Radiobutton, InputGroup, FormGroup, TextInput, SingleSelect } from '@cloudron/pankow';
|
||||
@@ -55,6 +55,7 @@ async function onSendmailSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await props.refreshApp();
|
||||
sendmailBusy.value = false;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ async function onRecvmailSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await props.refreshApp();
|
||||
recvmailBusy.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,144 +1,37 @@
|
||||
<script setup>
|
||||
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { eventlogSource, eventlogDetails } from '../../utils.js';
|
||||
import EventlogList from '../EventlogList.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import { EVENTS } from '../../constants.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const busy = ref(true);
|
||||
|
||||
const eventlogs = ref([]);
|
||||
const availableActions = [
|
||||
{ id: EVENTS.APP_BACKUP, label: 'Backup started' },
|
||||
{ id: EVENTS.APP_BACKUP_FINISH, label: 'Backup finished' },
|
||||
{ id: EVENTS.APP_CONFIGURE, label: 'Reconfigured' },
|
||||
{ id: EVENTS.APP_INSTALL, label: 'Installed' },
|
||||
{ id: EVENTS.APP_RESTORE, label: 'Restored' },
|
||||
{ id: EVENTS.APP_UNINSTALL, label: 'Uninstalled' },
|
||||
{ id: EVENTS.APP_UPDATE, label: 'Update started' },
|
||||
{ id: EVENTS.APP_UPDATE_FINISH, label: 'Update finished' },
|
||||
{ id: EVENTS.APP_LOGIN, label: 'Log in' },
|
||||
{ id: EVENTS.APP_OOM, label: 'Out of memory' },
|
||||
{ id: EVENTS.APP_DOWN, label: 'Down' },
|
||||
{ id: EVENTS.APP_UP, label: 'Up' },
|
||||
{ id: EVENTS.APP_START, label: 'Started' },
|
||||
{ id: EVENTS.APP_STOP, label: 'Stopped' },
|
||||
{ id: EVENTS.APP_RESTART, label: 'Restarted' },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await appsModel.getEvents(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(e => {
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, props.app),
|
||||
source: eventlogSource(e, props.app),
|
||||
};
|
||||
});
|
||||
|
||||
busy.value = false;
|
||||
});
|
||||
async function fetchPage(filter, page, perPage) {
|
||||
return appsModel.getEvents(props.app.id, filter, page, perPage);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="eventlog-list pankow-no-desktop">
|
||||
<div class="eventlog-list-item" v-for="eventlog in eventlogs" :key="eventlog.id" :class="{ 'active': eventlog.isOpen }">
|
||||
<div @click="eventlog.isOpen = !eventlog.isOpen" style="display: flex; justify-content: space-between; padding: 0 10px" >
|
||||
<div style="white-space: nowrap;">
|
||||
{{ prettyLongDate(eventlog.raw.creationTime) }}
|
||||
<b style="margin-left: 10px">{{ eventlog.raw.action }}</b>
|
||||
</div>
|
||||
<div>{{ eventlog.source }}</div>
|
||||
</div>
|
||||
<div v-show="eventlog.isOpen">
|
||||
<div class="eventlog-details" style="margin-top: 10px; padding-top: 5px">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="eventlog-table pankow-no-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<template v-for="eventlog in eventlogs" :key="eventlog.id">
|
||||
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
|
||||
<td style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td>{{ eventlog.source }}</td>
|
||||
<td v-html="eventlog.details"></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<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>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<EventlogList :fetch-page="fetchPage" :app="app" :available-actions="availableActions" :show-toolbar="false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-spacing: 0px;
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.eventlog-filter {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
cursor: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-source {
|
||||
padding-left: 10px;
|
||||
padding-bottom: 10px;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
color: var(--pankow-text-color);
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.eventlog-list-item.active {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-list-item {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { onMounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { Button, ClipboardAction } from '@cloudron/pankow';
|
||||
import { prettyDate } from '@cloudron/pankow/utils';
|
||||
import { stripSsoInfo } from '../../utils.js';
|
||||
import { marked } from 'marked';
|
||||
import { renderSafeMarkdown, stripSsoInfo } from '../../utils.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
@@ -32,7 +31,6 @@ async function onAckChecklistItem(item, key) {
|
||||
hasOldChecklist.value = true;
|
||||
}
|
||||
|
||||
// Notes
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
@@ -82,6 +80,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.upstreamVersion }}</div>
|
||||
<div class="info-value" v-else-if="app.versionsUrl">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
|
||||
<div class="info-value" v-else>{{ app.manifest.dockerImage }}</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +101,13 @@ onMounted(() => {
|
||||
<div class="info-value" v-else>{{ app.manifest.version }} <ClipboardAction plain :value="app.manifest.version"/></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="app.versionsUrl">
|
||||
<div class="info-label">{{ $t('app.updates.info.packager') }}</div>
|
||||
<div class="info-value">
|
||||
<a :href="app.manifest.packagerUrl" target="_blank">{{ app.manifest.packagerName }}</a> (community)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.installedAt') }}</div>
|
||||
<div class="info-value">{{ prettyDate(app.creationTime) }}</div>
|
||||
@@ -121,14 +127,14 @@ onMounted(() => {
|
||||
|
||||
<div v-for="(item, key) in app.checklist" :key="key">
|
||||
<div class="checklist-item" v-if="!item.acknowledged">
|
||||
<span v-html="marked.parse(item.message)"></span>
|
||||
<span v-html="renderSafeMarkdown(item.message)"></span>
|
||||
<Button small plain tool style="margin-left: 10px;" @click="onAckChecklistItem(item, key)">{{ $t('main.dialog.done') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(item, key) in app.checklist" :key="key" v-show="showDoneChecklist">
|
||||
<div class="checklist-item checklist-item-acknowledged" v-if="item.acknowledged">
|
||||
<span v-html="marked.parse(item.message)"></span>
|
||||
<span v-html="renderSafeMarkdown(item.message)"></span>
|
||||
<span class="text-muted text-small">{{ item.changedBy }} - {{ prettyDate(item.changedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +149,7 @@ onMounted(() => {
|
||||
|
||||
<div>
|
||||
<div v-show="!editing">
|
||||
<div v-if="noteContent" v-html="marked.parse(stripSsoInfo(noteContent, app.sso))"></div>
|
||||
<div v-if="noteContent" v-html="renderSafeMarkdown(stripSsoInfo(noteContent, app.sso))"></div>
|
||||
<div v-else class="text-muted hand" @click="onEdit()">{{ placeholder }}</div>
|
||||
</div>
|
||||
<div v-show="editing">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed, inject } from 'vue';
|
||||
import { ref, useTemplateRef, onMounted, computed } from 'vue';
|
||||
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { isValidDomain } from '@cloudron/pankow/utils';
|
||||
import { ISTATES } from '../../constants.js';
|
||||
@@ -8,18 +8,17 @@ import PortBindings from '../PortBindings.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import DomainsModel from '../../models/DomainsModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const domains = ref([]);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const errorObject = ref({});
|
||||
const overwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref([]);
|
||||
const domain = ref('');
|
||||
const subdomain = ref('');
|
||||
const secondaryDomains = ref({});
|
||||
@@ -55,40 +54,46 @@ function onAddRedirect() {
|
||||
});
|
||||
}
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (!domain.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function resetDnsOverwrite() {
|
||||
needsOverwriteDns.value = [];
|
||||
overwriteDns.value = false;
|
||||
errorMessage.value = '';
|
||||
}
|
||||
|
||||
const checkForDomains = [{
|
||||
domain: domain.value,
|
||||
subdomain: subdomain.value,
|
||||
}];
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[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 *.
|
||||
if (isFormValid.value) {
|
||||
const checkForDomains = [{ domain: domain.value, subdomain: subdomain.value }];
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[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 });
|
||||
}
|
||||
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
|
||||
|
||||
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) isFormValid.value = false;
|
||||
}
|
||||
|
||||
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function onRemoveRedirect(index) {
|
||||
redirects.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
errorMessage.value = '';
|
||||
errorObject.value = {};
|
||||
needsOverwriteDns.value = false;
|
||||
needsOverwriteDns.value = [];
|
||||
|
||||
const checkForDomains = [{
|
||||
domain: domain.value,
|
||||
@@ -99,6 +104,7 @@ async function onSubmit() {
|
||||
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 });
|
||||
|
||||
const conflicting = [];
|
||||
for (const d of checkForDomains) {
|
||||
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
|
||||
if (error) {
|
||||
@@ -107,16 +113,19 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
if (result.needsOverwrite && !overwriteDns.value) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = true;
|
||||
return;
|
||||
}
|
||||
if (result.needsOverwrite) conflicting.push((d.subdomain ? d.subdomain + '.' : '') + d.domain);
|
||||
}
|
||||
|
||||
if (conflicting.length > 0 && !overwriteDns.value) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = conflicting;
|
||||
errorMessage.value = `DNS records of ${conflicting.join(', ')} already exist`;
|
||||
return;
|
||||
}
|
||||
|
||||
// only use enabled ports
|
||||
const ports = {};
|
||||
const portsCombined = Object.assign(tcpPorts.value || {}, udpPorts.value || {});
|
||||
const portsCombined = Object.assign({}, tcpPorts.value || {}, udpPorts.value || {});
|
||||
for (const env in portsCombined) {
|
||||
if (portsCombined[env].enabled) {
|
||||
ports[env] = portsCombined[env].value;
|
||||
@@ -140,6 +149,7 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await props.refreshApp();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
@@ -190,23 +200,25 @@ onMounted(async () => {
|
||||
}
|
||||
else console.error(`Portbinding ${p} not known in manifest!`);
|
||||
}
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" novalidate>
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.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"/>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.location.location') }}</label>
|
||||
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<TextInput style="flex-grow: 1" v-model="subdomain" @input="resetDnsOverwrite()" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="isNoopOrManual(domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((subdomain ? subdomain + '.' : '') + domain) })"></div>
|
||||
<!-- Button just to offset the same margin on the right to align location input when alias or redirects are visible -->
|
||||
@@ -218,8 +230,8 @@ onMounted(async () => {
|
||||
<label :for="'secondaryDomainInput' + item.containerPort">{{ item.title }}</label>
|
||||
<small>{{ item.description }}</small>
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" :id="'secondaryDomainInput' + item.containerPort" v-model="item.subdomain" :placeholder="$t('appstore.installDialog.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<TextInput style="flex-grow: 1" :id="'secondaryDomainInput' + item.containerPort" v-model="item.subdomain" @input="resetDnsOverwrite()" :placeholder="$t('appstore.installDialog.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</FormGroup>
|
||||
@@ -232,8 +244,8 @@ onMounted(async () => {
|
||||
<div v-for="(item, index) in aliases" :key="item" style="margin-bottom: 10px">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" @input="resetDnsOverwrite()" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10"/>
|
||||
</InputGroup>
|
||||
<Button danger tool :disabled="busy" icon="fa-solid fa-trash" @click="onRemoveAlias(index)"/>
|
||||
</div>
|
||||
@@ -251,8 +263,8 @@ onMounted(async () => {
|
||||
<div v-for="(item, index) in redirects" :key="item" style="margin-bottom: 10px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" @input="resetDnsOverwrite()" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10"/>
|
||||
</InputGroup>
|
||||
<Button danger tool :disabled="busy" icon="fa-solid fa-trash" @click="onRemoveRedirect(index)"/>
|
||||
</div>
|
||||
@@ -270,12 +282,11 @@ onMounted(async () => {
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<Checkbox v-if="needsOverwriteDns.length" v-model="overwriteDns" :label="$t('app.location.overwriteDns', { domains: needsOverwriteDns.join(', ') })"/>
|
||||
<br v-if="needsOverwriteDns.length"/>
|
||||
|
||||
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
|
||||
<br v-if="needsOverwriteDns"/>
|
||||
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.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 || !isFormValid || (needsOverwriteDns.length > 0 && !overwriteDns)">{{ $t('app.location.saveAction') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { taskNameFromInstallationState } from '../../utils.js';
|
||||
import { ISTATES } from '../../constants.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const busyRepair = ref(false);
|
||||
@@ -28,8 +28,8 @@ async function onToggleDebugMode() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
// let the task start
|
||||
setTimeout(() => { debugModeBusy.value = false; }, 4000);
|
||||
await props.refreshApp();
|
||||
debugModeBusy.value = false;
|
||||
}
|
||||
|
||||
async function onRepair() {
|
||||
@@ -42,7 +42,8 @@ async function onRepair() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => { busyRepair.value = false; }, 4000);
|
||||
await props.refreshApp();
|
||||
busyRepair.value = false;
|
||||
}
|
||||
|
||||
async function onRestart() {
|
||||
|
||||
@@ -10,7 +10,7 @@ import SystemModel from '../../models/SystemModel.js';
|
||||
const appsModel = AppsModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const memoryLimitBusy = ref(false);
|
||||
const memoryLimit = ref(0);
|
||||
@@ -33,8 +33,8 @@ async function onSubmitMemoryLimit() {
|
||||
const [error] = await appsModel.configure(props.app.id, 'memory_limit', { memoryLimit: limit });
|
||||
if (error) return console.error(error);
|
||||
|
||||
// give polling some time
|
||||
setTimeout(() => memoryLimitBusy.value = false, 4000);
|
||||
await props.refreshApp();
|
||||
memoryLimitBusy.value = false;
|
||||
}
|
||||
|
||||
async function onSubmitCpuQuota() {
|
||||
@@ -44,9 +44,8 @@ async function onSubmitCpuQuota() {
|
||||
if (error) return console.error(error);
|
||||
|
||||
currentCpuQuota.value = parseInt(cpuQuota.value);
|
||||
|
||||
// give polling some time
|
||||
setTimeout(() => cpuQuotaBusy.value = false, 4000);
|
||||
await props.refreshApp();
|
||||
cpuQuotaBusy.value = false;
|
||||
}
|
||||
|
||||
async function onSubmitDevices() {
|
||||
@@ -70,11 +69,9 @@ async function onSubmitDevices() {
|
||||
return;
|
||||
}
|
||||
|
||||
// give polling some time
|
||||
setTimeout(() => {
|
||||
devicesBusy.value = false;
|
||||
currentDevices.value = Object.keys(devs);
|
||||
}, 4000);
|
||||
currentDevices.value = Object.keys(devs);
|
||||
await props.refreshApp();
|
||||
devicesBusy.value = false;
|
||||
}
|
||||
|
||||
const devicesChanged = computed(() => {
|
||||
@@ -116,20 +113,20 @@ onMounted(async () => {
|
||||
<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>
|
||||
<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" />
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" step="134217728" :min="memoryTicks[0]" :max="memoryTicks[memoryTicks.length-1]" list="memoryLimitTicks" :disabled="memoryLimitBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || !!app.taskId" />
|
||||
<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.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>
|
||||
<div description>{{ $t('app.resources.cpu.description') }}</div>
|
||||
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" />
|
||||
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" :disabled="cpuQuotaBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || !!app.taskId" />
|
||||
<datalist id="cpuQuotaTicks">
|
||||
<option value="25"></option>
|
||||
<option value="50"></option>
|
||||
@@ -137,12 +134,12 @@ onMounted(async () => {
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<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>
|
||||
<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.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">
|
||||
<fieldset :disabled="devicesBusy || (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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ISTATES } from '../../constants.js';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const { app } = defineProps([ 'app' ]);
|
||||
const { app, refreshApp } = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
@@ -24,6 +24,7 @@ async function onTurnChange(value) {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshApp();
|
||||
turnBusy.value = false;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ async function onRedisChange(value) {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshApp();
|
||||
redisBusy.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ISTATES } from '../../constants.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import VolumesModel from '../../models/VolumesModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const volumesModel = VolumesModel.create();
|
||||
@@ -56,9 +56,8 @@ async function onSubmitMove() {
|
||||
}
|
||||
|
||||
originalVolumeId.value = volumeId.value;
|
||||
|
||||
// give app refresh some time, ideally we wait for the task
|
||||
setTimeout(() => moveBusy.value = false, 4000);
|
||||
await props.refreshApp();
|
||||
moveBusy.value = false;
|
||||
}
|
||||
|
||||
function onMountAdd() {
|
||||
@@ -90,10 +89,9 @@ async function onSubmitMounts() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
// make a copy, cannot clone due to Proxy objects
|
||||
originalMounts.value = mounts.value.map(m => { return { volumeId: m.volumeId, readOnly: m.readOnly }; });
|
||||
|
||||
setTimeout(() => mountsBusy.value = false, 2000);
|
||||
await props.refreshApp();
|
||||
mountsBusy.value = false;
|
||||
}
|
||||
|
||||
const mountsValid = computed(() => {
|
||||
@@ -176,7 +174,7 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup v-if="volumeId !== DEFAULT_VOLUME_ID">
|
||||
<label for="volumePrefixInput">Subdirectory</label>
|
||||
<TextInput id="volumePrefixInput" placeholder="Prefix within the Volume" v-model="volumePrefix" />
|
||||
<TextInput id="volumePrefixInput" v-model="volumePrefix" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -225,7 +223,7 @@ onMounted(async () => {
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || !!app.taskId || (!app.error && !mountsChanged) || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
|
||||
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || !!app.taskId || !mountsChanged || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ const t = i18n.t;
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, InputDialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { APP_TYPES } from '../../constants.js';
|
||||
import { APP_TYPES, RSTATES, ISTATES } from '../../constants.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
@@ -26,11 +26,14 @@ async function onUninstall() {
|
||||
confirmLabel: t('app.uninstallDialog.uninstallAction'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
autoCloseOnConfirm: false,
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await appsModel.uninstall(props.app.id);
|
||||
inputDialog.value.close();
|
||||
|
||||
if (error) return console.error(error);
|
||||
|
||||
window.location.href = '/#/apps';
|
||||
@@ -44,17 +47,61 @@ async function onArchive() {
|
||||
message: t('app.archiveDialog.description', { app: (props.app.label || props.app.fqdn), date: prettyLongDate(latestBackup.value.creationTime) }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('app.archive.action'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
autoCloseOnConfirm: false,
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await appsModel.archive(props.app.id, latestBackup.value.id);
|
||||
inputDialog.value.close();
|
||||
|
||||
if (error) return console.error(error);
|
||||
|
||||
window.location.href = '/#/apps';
|
||||
}
|
||||
|
||||
const TARGET_RUN_STATE = {
|
||||
START: Symbol('start'),
|
||||
STOP: Symbol('stop'),
|
||||
};
|
||||
|
||||
function targetRunState() {
|
||||
// if we have an error, we want to retry the pending state, otherwise toggle the runstate
|
||||
if (props.app.error) {
|
||||
if (props.app.error.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
} else {
|
||||
if (props.app.runState === RSTATES.STOPPED) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRunStateBusy = ref(false);
|
||||
async function onStartApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.start(props.app.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
async function onStopApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.stop(props.app.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await appsModel.backups(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
@@ -75,6 +122,22 @@ onMounted(async () => {
|
||||
<div>
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED && targetRunState() === TARGET_RUN_STATE.START">
|
||||
<label>{{ $t('app.start.title') }}</label>
|
||||
<div v-html="$t('app.start.description')"></div>
|
||||
<br/>
|
||||
<Button primary :loading="toggleRunStateBusy" :disabled="app.error || toggleRunStateBusy" @click="onStartApp()">{{ $t('app.start.action') }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED && targetRunState() === TARGET_RUN_STATE.STOP">
|
||||
<label>{{ $t('app.stop.title') }}</label>
|
||||
<div v-html="$t('app.stop.description')"></div>
|
||||
<br/>
|
||||
<Button primary :loading="toggleRunStateBusy" :disabled="app.error || toggleRunStateBusy" @click="onStopApp()">{{ $t('app.stop.action') }}</Button>
|
||||
</div>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED">
|
||||
<label>{{ $t('app.archive.title') }}</label>
|
||||
<div v-html="$t('app.archive.description')"></div>
|
||||
|
||||
@@ -7,13 +7,11 @@ import { ISTATES } from '../../constants.js';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refresh-app' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
@@ -41,7 +39,7 @@ async function onAutoUpdatesEnabledChange(value) {
|
||||
async function waitForTask(id) {
|
||||
if (!id) return;
|
||||
|
||||
const [error, result] = await tasksModel.get(id);
|
||||
const [error, result] = await appsModel.getAppTask(props.app.id, id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
// task done, refresh menu
|
||||
@@ -59,6 +57,8 @@ async function onCheck() {
|
||||
const [error] = await appsModel.checkUpdate(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await props.refreshApp();
|
||||
|
||||
busyCheck.value = false;
|
||||
}
|
||||
|
||||
@@ -66,10 +66,17 @@ async function onUpdate() {
|
||||
busyUpdate.value = true;
|
||||
updateError.value = '';
|
||||
|
||||
const [error, result] = await appsModel.update(props.app.id, props.app.updateInfo.manifest, skipBackup.value);
|
||||
let appData = '';
|
||||
if (props.app.appStoreId) {
|
||||
appData = { manifest: props.app.updateInfo.manifest };
|
||||
} else if (props.app.versionsUrl) {
|
||||
appData = { versionsUrl: `${props.app.versionsUrl}@${props.app.updateInfo.manifest.version}` };
|
||||
}
|
||||
|
||||
const [error, result] = await appsModel.update(props.app.id, appData, skipBackup.value);
|
||||
if (error) {
|
||||
busyUpdate.value = false;
|
||||
if (error.status === 400) updateError.value = error.body ? error.body.message : 'Internal error';
|
||||
if (error.status !== 202) updateError.value = error.body ? error.body.message : 'Internal error';
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
@@ -80,6 +87,8 @@ async function onUpdate() {
|
||||
|
||||
function onAskUpdate() {
|
||||
busyUpdate.value = false;
|
||||
updateError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
|
||||
@@ -112,26 +121,27 @@ onMounted(async () => {
|
||||
<div>{{ $t('app.updateDialog.changelogHeader', { version: app.updateInfo.manifest.version }) }}</div>
|
||||
<div class="changelog" v-html="marked.parse(app.updateInfo.manifest.changelog)"></div>
|
||||
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('app.updateDialog.skipBackupCheckbox')" />
|
||||
<div class="error-label" style="margin-top: 12px" v-if="updateError">{{ updateError }}</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<SettingsItem>
|
||||
<div>
|
||||
<label>{{ $t('app.updates.auto.title') }}</label>
|
||||
<div v-if="!app.appStoreId">{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
|
||||
<div v-else v-html="$t('app.updates.auto.description')"></div>
|
||||
</div>
|
||||
<Switch v-if="app.appStoreId" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
|
||||
<div v-if="app.appStoreId || app.versionsUrl" v-html="$t('app.updates.auto.description')"></div>
|
||||
<div v-else>{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
|
||||
</div>
|
||||
<Switch v-if="app.appStoreId || app.versionsUrl" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
|
||||
</SettingsItem>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<div v-if="app.appStoreId">
|
||||
<div v-if="app.appStoreId || app.versionsUrl">
|
||||
<label>{{ $t('app.updatesTabTitle') }}</label>
|
||||
<div v-html="$t('app.updates.updates.description', { appStoreLink: 'https://www.cloudron.io/store/index.html' })"></div>
|
||||
<div>{{ $t('app.updates.updates.description') }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<Button v-if="app.appStoreId" @click="onCheck()" :disabled="busyCheck" :loading="busyCheck">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
<Button v-if="app.appStoreId || app.versionsUrl" @click="onCheck()" :disabled="busyCheck" :loading="busyCheck">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
|
||||
<hr v-if="app.updateInfo" style="margin-top: 20px"/>
|
||||
|
||||
@@ -142,7 +152,6 @@ onMounted(async () => {
|
||||
<div class="changelog" v-html="marked.parse(app.updateInfo.manifest.changelog)"></div>
|
||||
|
||||
<div class="error-label" style="margin-top: 12px" v-if="!features.appUpdates">{{ $t('app.updateDialog.subscriptionExpired') }}</div>
|
||||
<div class="error-label" style="margin-top: 12px" v-if="updateError">{{ updateError }}</div>
|
||||
<div class="error-label" style="margin-top: 12px" v-if="app.updateInfo.unstable">{{ $t('app.updateDialog.unstableWarning') }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
@@ -180,16 +180,19 @@ const REGIONS_HETZNER = [
|
||||
{ name: 'Nuremberg (NBG1)', value: 'https://nbg1.your-objectstorage.com' }
|
||||
];
|
||||
|
||||
// https://docs.digitalocean.com/products/platform/availability-matrix/
|
||||
// https://docs.digitalocean.com/products/spaces/details/availability/
|
||||
const REGIONS_DIGITALOCEAN = [
|
||||
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
|
||||
{ name: 'ATL1', value: 'https://atl1.digitaloceanspaces.com' },
|
||||
{ name: 'BLR1', value: 'https://blr1.digitaloceanspaces.com' },
|
||||
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
|
||||
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
|
||||
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
|
||||
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
|
||||
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
|
||||
{ name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' },
|
||||
{ name: 'SYD1', value: 'https://syd1.digitaloceanspaces.com' }
|
||||
{ name: 'SYD1', value: 'https://syd1.digitaloceanspaces.com' },
|
||||
{ name: 'TOR1', value: 'https://tor1.digitaloceanspaces.com' }
|
||||
];
|
||||
|
||||
// https://www.exoscale.com/datacenters/
|
||||
@@ -290,11 +293,9 @@ const STORAGE_PROVIDERS = [
|
||||
{ name: 'Cloudflare R2', value: 'cloudflare-r2' },
|
||||
{ name: 'Contabo Object Storage', value: 'contabo-objectstorage', regions: REGIONS_CONTABO },
|
||||
{ name: 'DigitalOcean Spaces', value: 'digitalocean-spaces', regions: REGIONS_DIGITALOCEAN },
|
||||
{ name: 'External/Local Disk (EXT4 or XFS)', value: 'disk' },
|
||||
{ name: 'EXT4 Disk', value: 'ext4' },
|
||||
{ name: 'Exoscale SOS', value: 'exoscale-sos', regions: REGIONS_EXOSCALE },
|
||||
{ name: 'Filesystem', value: 'filesystem' },
|
||||
{ name: 'Filesystem (Mount point)', value: 'mountpoint' }, // legacy
|
||||
{ name: 'Google Cloud Storage', value: 'gcs' },
|
||||
{ name: 'Hetzner Object Storage', value: 'hetzner-objectstorage', regions: REGIONS_HETZNER },
|
||||
{ name: 'IDrive e2', value: 'idrive-e2' },
|
||||
@@ -311,6 +312,7 @@ const STORAGE_PROVIDERS = [
|
||||
{ name: 'Vultr Object Storage', value: 'vultr-objectstorage', regions: REGIONS_VULTR },
|
||||
{ name: 'Wasabi', value: 'wasabi', regions: REGIONS_WASABI },
|
||||
{ name: 'XFS Disk', value: 'xfs' },
|
||||
{ name: 'User-managed Mount Point', value: 'mountpoint' },
|
||||
];
|
||||
|
||||
const BACKUP_FORMATS = [
|
||||
@@ -335,6 +337,113 @@ const RELAY_PROVIDERS = [
|
||||
{ provider: 'noop', name: 'Disable outgoing email' },
|
||||
];
|
||||
|
||||
// keep in sync with src/eventlog.js
|
||||
const EVENTS = Object.freeze({
|
||||
ACTIVATE: 'cloudron.activate',
|
||||
PROVISION: 'cloudron.provision',
|
||||
RESTORE: 'cloudron.restore',
|
||||
START: 'cloudron.start',
|
||||
UPDATE: 'cloudron.update',
|
||||
UPDATE_FINISH: 'cloudron.update.finish',
|
||||
INSTALL_FINISH: 'cloudron.install.finish',
|
||||
|
||||
APP_CLONE: 'app.clone',
|
||||
APP_CONFIGURE: 'app.configure',
|
||||
APP_REPAIR: 'app.repair',
|
||||
APP_INSTALL: 'app.install',
|
||||
APP_RESTORE: 'app.restore',
|
||||
APP_IMPORT: 'app.import',
|
||||
APP_UNINSTALL: 'app.uninstall',
|
||||
APP_UPDATE: 'app.update',
|
||||
APP_UPDATE_FINISH: 'app.update.finish',
|
||||
APP_BACKUP: 'app.backup',
|
||||
APP_BACKUP_FINISH: 'app.backup.finish',
|
||||
APP_LOGIN: 'app.login',
|
||||
APP_OOM: 'app.oom',
|
||||
APP_UP: 'app.up',
|
||||
APP_DOWN: 'app.down',
|
||||
APP_START: 'app.start',
|
||||
APP_STOP: 'app.stop',
|
||||
APP_RESTART: 'app.restart',
|
||||
|
||||
ARCHIVES_ADD: 'archives.add',
|
||||
ARCHIVES_DEL: 'archives.del',
|
||||
|
||||
BACKUP_FINISH: 'backup.finish',
|
||||
BACKUP_START: 'backup.start',
|
||||
BACKUP_CLEANUP_START: 'backup.cleanup.start',
|
||||
BACKUP_CLEANUP_FINISH: 'backup.cleanup.finish',
|
||||
BACKUP_INTEGRITY_START: 'backup.integrity.start',
|
||||
BACKUP_INTEGRITY_FINISH: 'backup.integrity.finish',
|
||||
|
||||
BACKUP_SITE_ADD: 'backupsite.add',
|
||||
BACKUP_SITE_REMOVE: 'backupsite.remove',
|
||||
BACKUP_SITE_UPDATE: 'backupsite.update',
|
||||
|
||||
BRANDING_NAME: 'branding.name',
|
||||
BRANDING_FOOTER: 'branding.footer',
|
||||
BRANDING_AVATAR: 'branding.avatar',
|
||||
|
||||
CERTIFICATE_NEW: 'certificate.new',
|
||||
CERTIFICATE_RENEWAL: 'certificate.renew',
|
||||
CERTIFICATE_CLEANUP: 'certificate.cleanup',
|
||||
|
||||
DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',
|
||||
|
||||
DIRECTORY_SERVER_CONFIGURE: 'directoryserver.configure',
|
||||
|
||||
DOMAIN_ADD: 'domain.add',
|
||||
DOMAIN_UPDATE: 'domain.update',
|
||||
DOMAIN_REMOVE: 'domain.remove',
|
||||
|
||||
EXTERNAL_LDAP_CONFIGURE: 'externalldap.configure',
|
||||
|
||||
GROUP_ADD: 'group.add',
|
||||
GROUP_REMOVE: 'group.remove',
|
||||
GROUP_UPDATE: 'group.update',
|
||||
GROUP_MEMBERSHIP: 'group.membership',
|
||||
|
||||
MAIL_LOCATION: 'mail.location',
|
||||
MAIL_ENABLED: 'mail.enabled',
|
||||
MAIL_DISABLED: 'mail.disabled',
|
||||
MAIL_MAILBOX_ADD: 'mail.box.add',
|
||||
MAIL_MAILBOX_REMOVE: 'mail.box.remove',
|
||||
MAIL_MAILBOX_UPDATE: 'mail.box.update',
|
||||
MAIL_LIST_ADD: 'mail.list.add',
|
||||
MAIL_LIST_REMOVE: 'mail.list.remove',
|
||||
MAIL_LIST_UPDATE: 'mail.list.update',
|
||||
|
||||
REGISTRY_ADD: 'registry.add',
|
||||
REGISTRY_UPDATE: 'registry.update',
|
||||
REGISTRY_DEL: 'registry.del',
|
||||
|
||||
SERVICE_CONFIGURE: 'service.configure',
|
||||
SERVICE_REBUILD: 'service.rebuild',
|
||||
SERVICE_RESTART: 'service.restart',
|
||||
|
||||
USER_ADD: 'user.add',
|
||||
USER_LOGIN: 'user.login',
|
||||
USER_LOGIN_GHOST: 'user.login.ghost',
|
||||
USER_LOGOUT: 'user.logout',
|
||||
USER_REMOVE: 'user.remove',
|
||||
USER_UPDATE: 'user.update',
|
||||
USER_TRANSFER: 'user.transfer',
|
||||
|
||||
USER_DIRECTORY_PROFILE_CONFIG_UPDATE: 'userdirectory.profileconfig.update',
|
||||
|
||||
VOLUME_ADD: 'volume.add',
|
||||
VOLUME_UPDATE: 'volume.update',
|
||||
VOLUME_REMOUNT: 'volume.remount',
|
||||
VOLUME_REMOVE: 'volume.remove',
|
||||
|
||||
DYNDNS_UPDATE: 'dyndns.update',
|
||||
|
||||
SUPPORT_TICKET: 'support.ticket',
|
||||
SUPPORT_SSH: 'support.ssh',
|
||||
|
||||
PROCESS_CRASH: 'system.crash',
|
||||
});
|
||||
|
||||
// named exports
|
||||
export {
|
||||
API_ORIGIN,
|
||||
@@ -364,6 +473,7 @@ export {
|
||||
REGIONS_HETZNER,
|
||||
REGIONS_WASABI,
|
||||
REGIONS_S3,
|
||||
EVENTS,
|
||||
RELAY_PROVIDERS,
|
||||
};
|
||||
|
||||
@@ -396,5 +506,6 @@ export default {
|
||||
REGIONS_HETZNER,
|
||||
REGIONS_WASABI,
|
||||
REGIONS_S3,
|
||||
EVENTS,
|
||||
RELAY_PROVIDERS,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import { API_ORIGIN } from './constants.js';
|
||||
const translations = {};
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: translations,
|
||||
warnHtmlInMessage: 'off',
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
@@ -45,12 +46,7 @@ async function main() {
|
||||
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
@@ -68,7 +64,7 @@ async function setLanguage(lang, profile = false) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
|
||||
i18n.global.locale = lang;
|
||||
i18n.global.locale.value = lang;
|
||||
}
|
||||
|
||||
export default main;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '@fontsource/inter';
|
||||
// import "@fontsource/inter/100.css"; // Specify weight
|
||||
// import "@fontsource/inter/200.css"; // Specify weight
|
||||
// import "@fontsource/inter/300.css"; // Specify weight
|
||||
import "@fontsource/inter/400.css"; // Specify weight
|
||||
import "@fontsource/inter/500.css"; // Specify weight
|
||||
// import "@fontsource/inter/600.css"; // Specify weight
|
||||
|
||||
import { tooltip, fallbackImage } from '@cloudron/pankow';
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.appPasswords];
|
||||
},
|
||||
async add(identifier, name) {
|
||||
async add(identifier, name, expiresAt) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/app_passwords`, { identifier, name }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/app_passwords`, { identifier, name, expiresAt }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -172,9 +172,41 @@ function create() {
|
||||
return {
|
||||
name: 'AppsModel',
|
||||
getTask,
|
||||
async install(manifest, config) {
|
||||
async listTasks(appId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/tasks`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.tasks];
|
||||
},
|
||||
async getAppTask(appId, taskId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/tasks/${taskId}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async stopAppTask(appId, taskId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${appId}/tasks/${taskId}/stop`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async install(appData, config) {
|
||||
const data = {
|
||||
appStoreId: manifest.id + '@' + manifest.version,
|
||||
subdomain: config.subdomain,
|
||||
domain: config.domain,
|
||||
secondaryDomains: config.secondaryDomains,
|
||||
@@ -188,6 +220,13 @@ function create() {
|
||||
backupId: config.backupId // when restoring from archive
|
||||
};
|
||||
|
||||
// Support both appstore apps (manifest) and community apps (versionsUrl)
|
||||
if (appData.versionsUrl) {
|
||||
data.versionsUrl = appData.versionsUrl;
|
||||
} else if (appData.manifest) {
|
||||
data.appStoreId = `${appData.manifest.id}@${appData.manifest.version}`;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps`, data, { access_token: accessToken });
|
||||
@@ -307,10 +346,10 @@ function create() {
|
||||
if (result.status !== 202) return [result];
|
||||
return [null];
|
||||
},
|
||||
async getEvents(id) {
|
||||
async getEvents(id, filter = {}, page = 1, per_page = 100) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { page: 1, per_page: 100, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { ...filter, page, per_page, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
@@ -329,12 +368,18 @@ function create() {
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.update];
|
||||
},
|
||||
async update(id, manifest, skipBackup = false) {
|
||||
async update(id, appData, skipBackup = false) {
|
||||
const data = {
|
||||
appStoreId: `${manifest.id}@${manifest.version}`,
|
||||
skipBackup: !!skipBackup,
|
||||
};
|
||||
|
||||
// Support both appstore apps (manifest) and community apps (versionsUrl)
|
||||
if (appData.versionsUrl) {
|
||||
data.versionsUrl = appData.versionsUrl;
|
||||
} else if (appData.manifest) {
|
||||
data.appStoreId = `${appData.manifest.id}@${appData.manifest.version}`;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/update`, data, { access_token: accessToken });
|
||||
|
||||
@@ -32,15 +32,26 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async checkIntegrity(id) {
|
||||
async startIntegrityCheck(id) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/check_integrity`, {}, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/start_integrity_check`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body.taskId];
|
||||
},
|
||||
async stopIntegrityCheck(id) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/stop_integrity_check`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async get(id) {
|
||||
|
||||
24
dashboard/src/models/CommunityModel.js
Normal file
24
dashboard/src/models/CommunityModel.js
Normal file
@@ -0,0 +1,24 @@
|
||||
|
||||
import { fetcher } from '@cloudron/pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async getApp(url, version) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
};
|
||||
@@ -23,6 +23,7 @@ const providers = [
|
||||
{ name: 'Porkbun', value: 'porkbun' },
|
||||
{ name: 'Vultr', value: 'vultr' },
|
||||
{ name: 'Wildcard', value: 'wildcard' },
|
||||
{ name: 'PowerDNS', value: 'powerdns' },
|
||||
{ name: 'Manual (not recommended)', value: 'manual' },
|
||||
{ name: 'No-op (only for development)', value: 'noop' }
|
||||
];
|
||||
@@ -90,6 +91,9 @@ function filterConfigForProvider(provider, config) {
|
||||
case 'porkbun':
|
||||
props = ['apikey', 'secretapikey'];
|
||||
break;
|
||||
case 'powerdns':
|
||||
props = ['apiUrl', 'apiKey'];
|
||||
break;
|
||||
}
|
||||
|
||||
const ret = {
|
||||
|
||||
@@ -6,10 +6,10 @@ function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async search(actions, search, page, per_page) {
|
||||
async search(filter, page, per_page) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/eventlog`, { actions, search, page, per_page, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/eventlog`, { ...filter, page, per_page, access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ function ab2str(buf) {
|
||||
return String.fromCharCode.apply(null, new Uint16Array(buf));
|
||||
}
|
||||
|
||||
export function create(type, id) {
|
||||
export function create(type, id, options = {}) {
|
||||
const accessToken = localStorage.token;
|
||||
const INITIAL_STREAM_LINES = 100;
|
||||
|
||||
@@ -46,6 +46,9 @@ export function create(type, id) {
|
||||
} else if (type === 'service') {
|
||||
streamApi = `/api/v1/services/${id}/logstream`;
|
||||
downloadApi = `/api/v1/services/${id}/logs`;
|
||||
} else if (type === 'task' && options.appId) {
|
||||
streamApi = `/api/v1/apps/${options.appId}/tasks/${id}/logstream`;
|
||||
downloadApi = `/api/v1/apps/${options.appId}/tasks/${id}/logs`;
|
||||
} else if (type === 'task') {
|
||||
streamApi = `/api/v1/tasks/${id}/logstream`;
|
||||
downloadApi = `/api/v1/tasks/${id}/logs`;
|
||||
@@ -79,7 +82,8 @@ export function create(type, id) {
|
||||
}
|
||||
|
||||
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
|
||||
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
const escaped = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
const html = escaped.replace(/\n/g, '<br>');
|
||||
|
||||
eventSource._lastMessage = { time, html };
|
||||
lineHandler(time, html);
|
||||
|
||||
@@ -292,10 +292,10 @@ function create() {
|
||||
if (result.status !== 202) return [result];
|
||||
return [null];
|
||||
},
|
||||
async eventlog(types, search, page, perPage) {
|
||||
async eventlog(types, search, page, perPage, from, to) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, from, to, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -6,16 +6,30 @@ function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async list(domain, search = '') {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailboxes`, { page: 1, per_page: 1000, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
async list(domain) {
|
||||
const perPage = 5000;
|
||||
|
||||
let page = 1;
|
||||
let mailboxes = [];
|
||||
|
||||
while (true) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailboxes`, { page, per_page: perPage, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
if (result.status !== 200) return [result];
|
||||
|
||||
mailboxes = mailboxes.concat(result.body.mailboxes);
|
||||
|
||||
if (result.body.mailboxes.length < perPage) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.mailboxes];
|
||||
return [null, mailboxes];
|
||||
},
|
||||
async get(domain, name) {
|
||||
let result;
|
||||
|
||||
@@ -6,10 +6,18 @@ function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async list(acknowledged = false) {
|
||||
async list(acknowledged = null, page = 1) {
|
||||
const query = {
|
||||
access_token: accessToken,
|
||||
page,
|
||||
per_page: 100
|
||||
};
|
||||
|
||||
if (acknowledged !== null) query.acknowledged = !!acknowledged;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 1000 });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, query);
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ function create() {
|
||||
|
||||
return null;
|
||||
},
|
||||
async setTwoFASecret() {
|
||||
async setTotpSecret() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_secret`, {}, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_secret`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -190,10 +190,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async enableTwoFA(totpToken) {
|
||||
async enableTotp(totpToken) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_enable`, { totpToken }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_enable`, { totpToken }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -204,7 +204,7 @@ function create() {
|
||||
async disableTwoFA(password) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_disable`, { password }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_disable`, { password }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
@@ -234,6 +234,39 @@ function create() {
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async getPasskeyRegistrationOptions() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register/options`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async registerPasskey(credential, name) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register`, { credential, name }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async deletePasskey(password) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/disable`, { password }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.update];
|
||||
},
|
||||
async getAutoupdatePattern() {
|
||||
async getAutoupdateConfig() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/updater/autoupdate_pattern`, { access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/updater/autoupdate_config`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -28,10 +28,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async setAutoupdatePattern(pattern) {
|
||||
async setAutoupdateConfig(schedule, policy) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/updater/autoupdate_pattern`, { pattern }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/updater/autoupdate_config`, { schedule, policy }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -199,10 +199,10 @@ function create() {
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.inviteLink];
|
||||
},
|
||||
async disableTwoFactorAuthentication(id) {
|
||||
async disableTotp(id) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/users/${id}/twofactorauthentication_disable`, {}, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/users/${id}/totp_disable`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ const mountTypes = [
|
||||
{ name: 'CIFS', value: 'cifs' },
|
||||
{ name: 'EXT4', value: 'ext4' },
|
||||
{ name: 'Filesystem', value: 'filesystem' },
|
||||
{ name: 'Filesystem (Mount point)', value: 'mountpoint' },
|
||||
{ name: 'NFS', value: 'nfs' },
|
||||
{ name: 'SSHFS', value: 'sshfs' },
|
||||
{ name: 'XFS', value: 'xfs' },
|
||||
{ name: 'User-managed Mount Point', value: 'mountpoint' },
|
||||
];
|
||||
|
||||
function filterConfigForMountType(mountType, config) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user