Compare commits

..

389 Commits

Author SHA1 Message Date
Girish Ramakrishnan 1f05a8d92a network: fix crash 2026-03-18 07:04:45 +05:30
Johannes Zellner 69ae2b2997 Update eslint to v10 2026-03-17 16:16:00 +01:00
Johannes Zellner b86e47de02 Update pankow 2026-03-17 16:14:51 +01:00
Girish Ramakrishnan ea7647f43c oidcserver: fix jwks_rsaonly response 2026-03-17 17:49:52 +05:30
Johannes Zellner ae7df52780 Reduce font-weight on no apps placeholder 2026-03-17 10:27:09 +01:00
Girish Ramakrishnan bc5737b9b0 passkey: implement passwordless login 2026-03-16 20:10:59 +05:30
Girish Ramakrishnan d0745d1914 2fa: provider passkey or totp 2026-03-16 18:49:12 +05:30
Girish Ramakrishnan 2b4c926a70 only clear passkeys on location change
calling this on initialize makes it lose all passkeys
2026-03-16 18:49:01 +05:30
Girish Ramakrishnan d922c1c80f missing translation for passkey login failure 2026-03-16 17:30:14 +05:30
Girish Ramakrishnan 67500a7689 profile: hasPasskey 2026-03-16 17:20:22 +05:30
Girish Ramakrishnan 1c8aa7440c lint 2026-03-16 17:05:44 +05:30
Girish Ramakrishnan d128dbec4c fix 2fa translations 2026-03-16 17:04:30 +05:30
Girish Ramakrishnan 676cb8810b loginview: remove reset password from tab order 2026-03-16 16:40:54 +05:30
Girish Ramakrishnan 189e3d5599 allow totp and passkey to co-exist 2026-03-16 16:38:48 +05:30
Girish Ramakrishnan 009d0b39f9 rename twoFactor* to totp 2026-03-16 16:38:42 +05:30
Girish Ramakrishnan 81a8aa7c3d login: move forgot password on top of the password
making way for passkey login
2026-03-16 15:59:00 +05:30
Johannes Zellner 6c6761d14b Update to vite 8 2026-03-16 08:37:19 +01:00
Johannes Zellner 7d2e3df929 Update frontend dependencies 2026-03-16 08:33:23 +01:00
Girish Ramakrishnan f334c696cb update: add policy to update apps separately from platform 2026-03-16 10:19:18 +05:30
Girish Ramakrishnan db974d72d5 oidcserver: permit origin "*" from localhost testing 2026-03-16 07:21:55 +05:30
Girish Ramakrishnan c15e342bb8 webadmin: remove the implicit flow
we now use pkce . main advantage is that we don't see the access token
in the url anymore.

in pkce, the auth code by itself is useless. need the verifier.

fixes #844
2026-03-15 17:38:27 +05:30
Girish Ramakrishnan dc1449c7b6 oidcserver: convert to trace 2026-03-15 17:32:03 +05:30
Girish Ramakrishnan 0b305caf58 sites: add conflict detection
Fixes #863
2026-03-15 14:59:35 +05:30
Girish Ramakrishnan 8f1f3645b2 app update: if backup fails, provide a notification
fixes #851
2026-03-15 14:48:07 +05:30
Girish Ramakrishnan 0079162efe notifications: only title is bold 2026-03-15 14:26:30 +05:30
Girish Ramakrishnan 7afec06d4c apps: operators can now view backup logs and manage the backup task
we spun off the app backup as a separate task and this is not tracked
by app.taskId .

fixes #856
2026-03-15 10:18:31 +05:30
Girish Ramakrishnan 29f85a8fd2 test: fix debug 2026-03-15 09:54:55 +05:30
Girish Ramakrishnan 6e0dc24eca rsync: escape U+2028/U+2029
JSON strings can contain unescaped U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR
characters while ECMAScript strings cannot. ES2019 now allows those unescaped.

The integrity code assumes that each JSON is a single line but that assumption does
not hold true when these characters are there in the string. Fix is to escape them.
2026-03-15 09:20:40 +05:30
Girish Ramakrishnan cee1180aa7 9.1.4 changes 2026-03-14 19:10:04 +05:30
Girish Ramakrishnan 6db2b55e63 oidcserver: custom templates for device login
the default one uses google fonts :/
2026-03-13 13:25:57 +05:30
Girish Ramakrishnan a3c038781f oidc: implement Device Authorization Grant 2026-03-13 12:44:39 +05:30
Girish Ramakrishnan 59c9e5397e ldapserver, directoryserver: all traces 2026-03-12 23:30:12 +05:30
Girish Ramakrishnan a4c253b9a9 users: modify verify log calls to trace
this is only useful when we are debugging and not useful to end user
without further context
2026-03-12 23:27:13 +05:30
Girish Ramakrishnan f12b4faf34 lint 2026-03-12 23:23:23 +05:30
Girish Ramakrishnan ff49759f42 promise-retry: rename api to log 2026-03-12 23:11:16 +05:30
Girish Ramakrishnan 01d0c738bc replace debug() with our custom logger
mostly we want trace() and log(). trace() can be enabled whenever
we want by flipping a flag and restarting box
2026-03-12 23:08:35 +05:30
Girish Ramakrishnan d57554a48c backup logs: make them much terse and concise
these are making the rsync logs massive. instead resort to reporting
progress based on file count. there is also a heartbeat timer for
"stuck" or "long downloading" files, every minute.
2026-03-12 19:40:46 +05:30
Girish Ramakrishnan b16b57f38b rsync: throttle log messages during download 2026-03-12 13:47:26 +05:30
Girish Ramakrishnan 12177446a2 backupcleaner: remove cleanupSnapshotSuperfluous
this removes the workaround for the rsync bug to make integrity
checks work. see ec15f29e40 and
2c12bee79b
2026-03-12 08:07:25 +05:30
Girish Ramakrishnan 61b15db958 Update translations 2026-03-12 07:39:45 +05:30
Girish Ramakrishnan 349e8f5139 notifications: add empty text, progress bar and inifinite scroll 2026-03-12 07:33:22 +05:30
Johannes Zellner f30482808b Workaround chrome quirks on file drop handling 2026-03-11 15:11:16 +01:00
Girish Ramakrishnan 79cdecdff6 graphite: fix aggregation of block/network read/write 2026-03-10 22:56:00 +05:30
Girish Ramakrishnan 336dee53cd metrics: pick last item in series
picking the first item for "max" is not correct
2026-03-10 22:25:15 +05:30
Girish Ramakrishnan 77022bbd7f restore: apply blocklist 2026-03-10 21:34:26 +05:30
Johannes Zellner df96df776d Wait for dashboard reload when version has changed 2026-03-10 16:28:17 +01:00
Girish Ramakrishnan 67bc803859 troubleshoot: ignore package.json 2026-03-10 20:24:32 +05:30
Girish Ramakrishnan 8ef56c6d91 reverseproxy: fix restore of trusted ips 2026-03-10 17:28:06 +05:30
Girish Ramakrishnan d377d1e1cf remove deprecated url 2026-03-10 15:15:17 +05:30
Girish Ramakrishnan 4209e4d90d Some progressbars for various views 2026-03-08 19:14:30 +05:30
Girish Ramakrishnan 83c85d02ee services: load all the view services together
box/cloudron service was appearing first and the rest were appearing
a while later
2026-03-08 18:56:57 +05:30
Girish Ramakrishnan 866b72d029 services: distinguish error state and idle state for stopped containers 2026-03-08 18:36:24 +05:30
Girish Ramakrishnan 4bc0f44789 services: lazy start services / on demand services
services are now stopped when no app is using them.

on start up, services are always created and run. they are later
stopped when unused. it's this way to facilitate the upgrade code
path. the database meta files have to be upgraded even if no app is using them.
the other hook to stop unused services is at the end of an app task.

maybe mail container is a candidate for the future where all sending is no-op.
But give this is rare, it's not implemented.
2026-03-08 18:35:50 +05:30
Girish Ramakrishnan 99c55cb22f services: enforce min memory limit 2026-03-05 21:25:31 +05:30
Girish Ramakrishnan 74c73c695f mongodb: set min memory to 2GB 2026-03-05 21:25:31 +05:30
Johannes Zellner b972891337 If we have no released version of an app also show an error instead of the app install dialog 2026-03-05 16:27:59 +01:00
Girish Ramakrishnan 57515d54db 9.1.3 changes 2026-03-05 16:43:53 +05:30
Johannes Zellner 0ff8dcc8e9 Remove 'Dashboard' from dashboard page title 2026-03-05 12:09:23 +01:00
Girish Ramakrishnan 38efa6a2ba integrity: show failure messages 2026-03-05 16:24:46 +05:30
Johannes Zellner 6306625184 Add vue/no-root-v-if linter rule 2026-03-05 11:53:53 +01:00
Johannes Zellner 1803ab303f Do not use v-if in toplevel nodes of a component 2026-03-05 11:41:23 +01:00
Johannes Zellner e72dd7c845 Fix warning about missing dom id 2026-03-05 11:40:50 +01:00
Johannes Zellner 87288caeb9 Stop using vue-i18n legacy api 2026-03-05 11:14:18 +01:00
Elias Hackradt 79b519e462 Fix: add label to user.value object 2026-03-05 10:05:02 +00:00
Girish Ramakrishnan 5f8ea2aecc integrity: skip check of backups with no integrity info 2026-03-04 21:18:20 +05:30
Girish Ramakrishnan 94bc52a0c3 rsync: typo 2026-03-04 20:48:00 +05:30
Girish Ramakrishnan fed51bdcd9 backupintegrity: add percent progress 2026-03-04 18:30:16 +05:30
Girish Ramakrishnan 5fc9689645 backup: fix alignment of progressbar and logs button 2026-03-04 17:24:46 +05:30
Girish Ramakrishnan 7c6a783fc8 tasks: fix progress percents
mostly rebalancing the percents
2026-03-04 16:37:34 +05:30
Girish Ramakrishnan 764d479d7f 9.1.2 changes 2026-03-04 15:26:45 +05:30
Girish Ramakrishnan ec15f29e40 syncer: clean up superfluous files 2026-03-04 14:00:54 +05:30
Girish Ramakrishnan 2c12bee79b syncer: fix bug with a file and dir having same prefix
if we had say "app/" and "application.json", the syncer logic
was skipping over application.json because it starts with "app".
This would lead to application.json not getting deleted in the snapshot
and result in superfluous files.
2026-03-04 13:59:35 +05:30
Johannes Zellner 1120866b75 Make renewalInfo explicitly fallback to null to be visible in stringify 2026-03-04 08:46:27 +01:00
Johannes Zellner b362c069e5 fix crash, where result may be undefined on error 2026-03-04 08:39:31 +01:00
Johannes Zellner 4b6b18c182 Try to detect and set the content type of app icons 2026-03-03 22:06:50 +01:00
Johannes Zellner 80efc8c60c Update dashboard dependencies 2026-03-03 21:07:33 +01:00
Girish Ramakrishnan 99168157fc integrity: size is not a function 2026-03-03 20:24:26 +05:30
Girish Ramakrishnan 23c3263562 integrity: show log link
in the previous approach, we used to clear the taskId after
the integrity check completes. for one, we lose track of the
task (to show the logs). for another, we have to clear these
taskId on platform startup to handle crashes.

in the new approach, we keep the taskId and use the task's
active flag to determine if task is active.
2026-03-03 18:41:57 +05:30
Girish Ramakrishnan 1179a78fe1 integrity: center align the indicator 2026-03-03 17:23:16 +05:30
Girish Ramakrishnan 82677ddd85 backup: show integrity column for dependsOn backups 2026-03-03 17:18:28 +05:30
Girish Ramakrishnan 31f29e9086 integrity: show status in the info dialog 2026-03-03 16:57:55 +05:30
Girish Ramakrishnan 3b3e606573 integrity: better log messages 2026-03-03 16:13:44 +05:30
Girish Ramakrishnan 18b713cec3 oom: fix crash when source build ooms 2026-03-02 21:10:47 +05:30
Johannes Zellner 2a6d385cea Support and prefer Dockerfile.cloudron in local builds 2026-03-02 12:06:27 +01:00
Johannes Zellner 4cc1926899 Also increase body upload for app update route to work well with source builds 2026-03-02 11:45:03 +01:00
Johannes Zellner 15b2c2b739 Add Czech translations 2026-03-02 11:36:48 +01:00
Johannes Zellner 197bf56271 Update translations 2026-03-02 11:36:48 +01:00
Girish Ramakrishnan 4110f4b8ce lint 2026-03-02 10:07:31 +05:30
Girish Ramakrishnan becbaca858 appstore: better tag/cateogry mapping 2026-03-02 09:59:43 +05:30
Girish Ramakrishnan add50257f6 appstore: add ai category 2026-03-02 09:14:49 +05:30
Girish Ramakrishnan f061ee5f88 Update modules 2026-03-02 08:40:19 +05:30
Girish Ramakrishnan 480b81b3dd postgres: update pgvector to 0.8.2 2026-03-02 08:37:26 +05:30
Girish Ramakrishnan 61d4a795ae apps: enable storage view in all error states 2026-02-28 02:20:25 +01:00
Girish Ramakrishnan cd89883dbb apps: move to error state if a volume is unavailable 2026-02-28 01:02:22 +01:00
Girish Ramakrishnan d5a729a2ba remove the placeholder 2026-02-27 17:10:11 +01:00
Johannes Zellner b41533c278 Set appsview filters early to avoid flickering 2026-02-27 11:11:11 +01:00
Girish Ramakrishnan 04758587b4 update: throw error when update failed 2026-02-26 11:09:55 +01:00
Johannes Zellner b6b0969879 Allow to reset app filters when clicking on the menu entry 2026-02-26 11:05:08 +01:00
Johannes Zellner 18ef97fae6 Preserver app search and filter in query args to make back and reload work 2026-02-26 10:55:17 +01:00
Girish Ramakrishnan 333f052f86 backupsites: show warning if no site is selected for automatic backups 2026-02-26 09:03:39 +01:00
Girish Ramakrishnan 7dd40eccf3 backupsites: display automatic update backups flag 2026-02-26 08:25:40 +01:00
Girish Ramakrishnan db728840a0 backupsite: add space after last run 2026-02-26 08:00:30 +01:00
Girish Ramakrishnan 8906436824 mail: fix css styling of expected value 2026-02-26 02:03:21 +01:00
Johannes Zellner e8dedb04a5 Clear oidc client add or edit error when dialog opens 2026-02-25 21:16:17 +01:00
Elias Hackradt d4b581c007 Make MailDomainStatus expected value flexible and add copy button 2026-02-25 17:19:39 +00:00
Johannes Zellner a900beb3fd First delete mailpassword for oidc clients then the client itself 2026-02-25 18:02:31 +01:00
Johannes Zellner 19a0f77c53 Do not add empty mailclient claim unless requested 2026-02-25 16:15:35 +01:00
Johannes Zellner 6dbd97ba14 Only generate mailpassword and fetch mailboxes if the oidc client wants the mailclient scope 2026-02-25 16:07:46 +01:00
Girish Ramakrishnan d2fbea8e39 update: typo 2026-02-25 15:53:03 +01:00
Girish Ramakrishnan 86a49c6223 Fix update check translation 2026-02-25 15:24:10 +01:00
Girish Ramakrishnan e97f9b47d7 weblate: add find 2026-02-25 15:14:38 +01:00
Girish Ramakrishnan ee3ed5f660 Update lock 2026-02-25 14:53:55 +01:00
Girish Ramakrishnan 3446f3d1e0 add to changes 2026-02-25 14:52:43 +01:00
Girish Ramakrishnan 69d03a7a42 Update pankow 2026-02-25 14:49:56 +01:00
Johannes Zellner bab95cbefa Use pagination in model code for mailbox listing 2026-02-25 11:24:40 +01:00
Johannes Zellner 5ef23fa49a Bump mailbox list page size up to 10k for now 2026-02-25 10:14:23 +01:00
Girish Ramakrishnan f4ff63485a domains: validate well known 2026-02-25 05:55:14 +01:00
Girish Ramakrishnan c20fbe8635 domains: set caldav/cardav correctly on dialog open 2026-02-25 05:46:41 +01:00
Johannes Zellner 662cf65ff2 Only update domains list if component is still mounted 2026-02-24 10:58:07 +01:00
Johannes Zellner 7ded517b20 Protect against crash in EventlogList component on quick unloads 2026-02-24 10:28:13 +01:00
Girish Ramakrishnan 4be31b0dad update: if no backup site, show error message 2026-02-24 05:55:56 +01:00
Girish Ramakrishnan dc439ba5be install/update: ui must always set the appStoreId or versionsUrl 2026-02-24 05:31:13 +01:00
Girish Ramakrishnan 5ba8a05450 cifs: use rsync instead cp -aRl
for some reason, cp fails on synology cifs for some paths - immich's typesense dir
2026-02-23 17:47:05 +01:00
Girish Ramakrishnan 7ef19b318a add weblate script 2026-02-23 14:45:22 +01:00
Girish Ramakrishnan 2ac76ad852 2fa dialog translations updates 2026-02-23 14:45:14 +01:00
Girish Ramakrishnan f4598f81c9 debug: add CLOUDRON_DEBUG=1 env var 2026-02-23 10:53:06 +01:00
Girish Ramakrishnan c432dbb5bc Update translations 2026-02-23 10:50:29 +01:00
Girish Ramakrishnan d0f0bb799e 2fa: refactor into separate dialog
also rename routes to totp
2026-02-22 10:43:15 +01:00
Girish Ramakrishnan a98dbfdf4f integrity: link to the logs 2026-02-22 10:24:32 +01:00
Girish Ramakrishnan a71909acd3 Update lock file 2026-02-21 20:34:24 +01:00
Girish Ramakrishnan ea5953a397 Fix test 2026-02-21 20:27:55 +01:00
Girish Ramakrishnan 4ad9ccabe0 apps: refactor the updateInfo properties 2026-02-21 20:01:05 +01:00
Girish Ramakrishnan 17640d44fa Update changelog 2026-02-21 19:43:23 +01:00
Girish Ramakrishnan 812d471573 add backupCommand, restoreCommand, persistentDirs 2026-02-21 19:42:52 +01:00
Girish Ramakrishnan fa981d5a83 release: replace url with URL 2026-02-21 16:46:29 +01:00
Girish Ramakrishnan 202f2c6cb0 updater: keep updateInfo response same as in the app object 2026-02-21 16:43:03 +01:00
Girish Ramakrishnan 55359bfa24 Update haraka to 3.1.3 2026-02-21 14:13:50 +01:00
Girish Ramakrishnan 95fcfce9cd add "community" to packager info 2026-02-21 12:12:36 +01:00
Girish Ramakrishnan 3120a2c43f appstore: more state information 2026-02-21 12:05:56 +01:00
Girish Ramakrishnan 7ba3a59dea eventlog: add flag for source builds 2026-02-21 11:48:39 +01:00
Girish Ramakrishnan eb5f8fcfa1 community: better resolution of url 2026-02-21 11:17:47 +01:00
Girish Ramakrishnan 5014227028 community: validateForm on dialog open 2026-02-21 11:11:34 +01:00
Girish Ramakrishnan 7a76de2e4c reorder menu items to be alphabetical 2026-02-21 11:11:34 +01:00
Johannes Zellner de5692c1af Immediately refresh the app after checking for updates 2026-02-20 22:27:30 +01:00
Johannes Zellner 555e4f0e65 Only set postinstall pending in localstorage if a message exists in the manifest 2026-02-20 22:17:10 +01:00
Girish Ramakrishnan 723c670100 reverseproxy: fix crash 2026-02-20 19:46:43 +01:00
Johannes Zellner 2f951dc272 Support card/cal dav well-known endpoints 2026-02-20 15:46:43 +01:00
Girish Ramakrishnan 0daabdc21c reverseproxy: for large payloads, turn off proxy buffering 2026-02-20 09:59:21 +01:00
Girish Ramakrishnan 38a187e9fc apps: install route needs more upload limit 2026-02-20 09:54:13 +01:00
Girish Ramakrishnan 5a613231e0 apps: remove deprecated /api/v1/apps/install route 2026-02-20 09:48:07 +01:00
Girish Ramakrishnan 28a35e7260 community: handle unstable flag 2026-02-19 22:48:16 +01:00
Girish Ramakrishnan 461a5a780d community: resolve user provided url 2026-02-19 22:25:43 +01:00
Girish Ramakrishnan 207260821b reverseproxy: add forceRenewal check if only for completeness 2026-02-19 22:25:43 +01:00
Johannes Zellner 466527884f Fix acl logic for token inspection endpoint 2026-02-19 19:09:02 +01:00
Johannes Zellner 9d03eb2643 Check internal ACL during token introspection 2026-02-19 18:05:49 +01:00
Johannes Zellner c801202642 Fix variable usage after no-shadow fixes 2026-02-19 18:05:36 +01:00
Girish Ramakrishnan 95952fae75 update manifestformat 2026-02-19 14:40:29 +01:00
Girish Ramakrishnan f62629b513 mailpassword: fix test, remove unused function 2026-02-19 14:22:36 +01:00
Girish Ramakrishnan f04087815c Fix failing test 2026-02-19 13:48:48 +01:00
Girish Ramakrishnan 255b1c63d0 Update modules 2026-02-19 13:36:26 +01:00
Girish Ramakrishnan 9b5b8ddc22 dockerproxy: close all connections 2026-02-19 13:33:02 +01:00
Girish Ramakrishnan d0a66f1701 Back to mocha!
sorry i ever left you dear mocha

node:test has two major issues:
* --bail does not work and requires strange modules and incantations.
I was able to work around this with a custom module.

* the test reporter reports _after_ the suite is run. this makes debugging
really hard. the debugs that we print all happen before the test suite summary.
poor design overall.
2026-02-19 13:24:14 +01:00
Girish Ramakrishnan c176ac600b migrate tests to node:test 2026-02-19 10:13:22 +01:00
Girish Ramakrishnan cf0ab16533 fix test 2026-02-18 20:20:26 +01:00
Girish Ramakrishnan 03d0e2157e Update modules 2026-02-18 19:59:58 +01:00
Girish Ramakrishnan cdd5137ebe Update addons to latest builds 2026-02-18 17:44:18 +01:00
Johannes Zellner 0a924b2c29 Cleanup mailPasswords when oidc client is removed not only when an app is uninstalled 2026-02-18 17:15:06 +01:00
Johannes Zellner 43acecfc6e mailPasswords table should work with oidc clients not apps 2026-02-18 15:17:08 +01:00
Johannes Zellner 5e7e739589 Enable token inspection endpoints in oidc 2026-02-18 15:00:02 +01:00
Elias Hackradt 0b968b6a98 Use branding.getCloudronName(); for totp secret name metadata 2026-02-18 13:19:27 +00:00
Johannes Zellner f14dfb6c17 Fix typo 2026-02-18 11:27:45 +01:00
Johannes Zellner cb5ccd8166 Also auth against mailPasswords in ldapserver.js 2026-02-18 10:12:34 +01:00
Johannes Zellner bfbcbb686d Send an email accessToken alongside the mailclient claims 2026-02-18 10:12:34 +01:00
Johannes Zellner 744300744c Fix claim name to mailclient 2026-02-18 10:12:34 +01:00
Johannes Zellner 9bac099339 Add mailPassword table
This table stores email credentials for users using apps which use the
email addon
2026-02-18 10:12:34 +01:00
Johannes Zellner 135c9fb64d Support mailclient oidc claim
Only apps with addon email have access to the claims' scopes
2026-02-18 10:12:34 +01:00
Girish Ramakrishnan 4ed6fbbd74 eslint: add no-shadow 2026-02-18 08:18:37 +01:00
Girish Ramakrishnan 4d3e9dc49b Fix shadow of pipeline variable 2026-02-18 07:58:47 +01:00
Girish Ramakrishnan 319360f8d0 lint 2026-02-17 19:51:09 +01:00
Girish Ramakrishnan 3ef990b0bf Fix typo 2026-02-17 18:48:07 +01:00
Girish Ramakrishnan b8ae46b6df add passkey tests 2026-02-17 18:05:14 +01:00
Girish Ramakrishnan 113aba0897 remove the cors test 2026-02-17 17:15:30 +01:00
Girish Ramakrishnan a51672f3ee security: remove cors
I traced this back to a commit from 2014! 781495e662
2026-02-17 17:10:18 +01:00
Girish Ramakrishnan f08b3eb006 mail: bring eventlog up to speed 2026-02-17 16:18:37 +01:00
Girish Ramakrishnan 66f65093fc eventlog: fix the description in app context 2026-02-17 15:41:09 +01:00
Girish Ramakrishnan d78944e03b eventlog: add volume remount 2026-02-17 15:32:22 +01:00
Girish Ramakrishnan 2fe31b876f Add EVENTS to constants 2026-02-17 15:30:44 +01:00
Girish Ramakrishnan 9949ea364a eventlog: move to table view 2026-02-17 15:12:45 +01:00
Girish Ramakrishnan 77b7f7bfad eventlog: make a component and use it in app and system 2026-02-17 14:42:40 +01:00
Girish Ramakrishnan 8d4b458a22 eventlog: fix date range and empty string search 2026-02-17 13:49:10 +01:00
Girish Ramakrishnan 2df8e77733 eventlog: implement contextual highlight 2026-02-17 13:21:06 +01:00
Johannes Zellner c21011a17a support ID_CLI oidc client 2026-02-16 23:19:37 +01:00
Girish Ramakrishnan a11a691788 eventlog: add to/from date picker 2026-02-16 22:03:18 +01:00
Girish Ramakrishnan 81659d4bf2 eventlog: add params for from and to date 2026-02-16 20:52:02 +01:00
Girish Ramakrishnan aab20fd23e filemanager: move the title to top left like terminal 2026-02-16 14:21:46 +01:00
Johannes Zellner 5fad4dd034 Ensure users without totp nor passkey are forced to setup one if mandatory2FA is enabled 2026-02-16 14:01:14 +01:00
Johannes Zellner 7bc19e8185 Cleanup passkeys on user deletion 2026-02-16 13:52:04 +01:00
Johannes Zellner 45d0928ff9 Ask the user for confirmation on dashboard domain change 2026-02-16 12:33:09 +01:00
Johannes Zellner 9b768273f4 provide a global InputDialog for dashboard views 2026-02-16 12:32:43 +01:00
Johannes Zellner ef24b17a70 Drop all passkeys if the dashboard domain changes 2026-02-16 12:06:12 +01:00
Girish Ramakrishnan dfbe5aaa16 filemanager: remove breadcrumb now that we have treeview 2026-02-16 11:29:35 +01:00
Girish Ramakrishnan f499c9ada9 integrity: add eventlog status for historic checks 2026-02-15 23:40:23 +01:00
Girish Ramakrishnan c1a73aa62a integrity: just clear last info on a (re)start
this way if a user stops it midway, the old info is cleared
2026-02-15 23:26:06 +01:00
Girish Ramakrishnan 601e787500 rsync: fix integrity check 2026-02-15 23:17:23 +01:00
Girish Ramakrishnan d24bfabdc1 info: only show packager for custom apps 2026-02-15 20:14:05 +01:00
Girish Ramakrishnan 2c559d63f5 Fix bugs in esm migration 2026-02-15 20:11:58 +01:00
Girish Ramakrishnan b5a1554631 Fix various linter errors 2026-02-15 19:37:30 +01:00
Girish Ramakrishnan 510e1c7296 Fix missing import 2026-02-15 19:21:06 +01:00
Girish Ramakrishnan c6d8af5dc3 add to changelog 2026-02-15 19:05:40 +01:00
Girish Ramakrishnan adf884c2c4 add integrity check to system backups 2026-02-15 14:59:27 +01:00
Girish Ramakrishnan c7b321315c integrity: add stats 2026-02-15 14:41:10 +01:00
Girish Ramakrishnan 9f2eefcbb3 embed integrity check task in backup API responses
The UI is polling for the taskId, might as well attach it
2026-02-15 14:11:56 +01:00
Girish Ramakrishnan fc2e39f41b Rename getByIdentifierAndStatePaged to listByIdentifierAndStatePaged 2026-02-15 12:22:43 +01:00
Girish Ramakrishnan eae86d15ef Update modules 2026-02-14 19:49:42 +01:00
Girish Ramakrishnan 361d80da17 make tests work again 2026-02-14 19:45:10 +01:00
Girish Ramakrishnan 2597402496 make build work across server restart
tmp files disappear on server restart
2026-02-14 19:37:14 +01:00
Girish Ramakrishnan c8bc6f9ffe Update translations 2026-02-14 18:33:50 +01:00
Girish Ramakrishnan b0ef9238ff Show proper title/description for the new start/stop section 2026-02-14 18:31:06 +01:00
Girish Ramakrishnan b71e503a01 more ESM cleanups 2026-02-14 16:52:16 +01:00
Girish Ramakrishnan e9f96593c3 reorder functions for no-use-before-define 2026-02-14 16:34:34 +01:00
Girish Ramakrishnan 36aa641cb9 migrate to "export default"
also, set no-use-before-define in linter
2026-02-14 15:43:24 +01:00
Girish Ramakrishnan ddb46646fa remove esm migration files 2026-02-14 15:12:07 +01:00
Girish Ramakrishnan 96dc79cfe6 Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
  (dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
  declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
  loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing

Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 15:11:45 +01:00
Girish Ramakrishnan e0e9f14a5e mail: increase solr timeout 2026-02-14 01:23:23 +01:00
Johannes Zellner b24e1142f8 Move app stop/start into uninstall again and add restart button in main toolbar 2026-02-13 18:37:49 +01:00
Johannes Zellner 0543b16de9 Update frontend dependencies 2026-02-13 17:51:36 +01:00
Johannes Zellner 8d46c09f95 Update pankow 2026-02-13 17:38:56 +01:00
Johannes Zellner 5724ca73b4 Add passkey support 2026-02-13 17:18:56 +01:00
Girish Ramakrishnan 3e09bef613 folderview: implement drop handler 2026-02-12 23:44:23 +01:00
Girish Ramakrishnan 627b1fe33f filemanager: implement tree view on the left 2026-02-12 22:55:24 +01:00
Girish Ramakrishnan 1aa270485c add changelog 2026-02-12 20:31:51 +01:00
Girish Ramakrishnan ae09c19b69 filemanager: open terminal in cwd 2026-02-12 20:30:57 +01:00
Johannes Zellner c5cf8eef1a Update pankow to v4 to fix TableView bug 2026-02-12 20:28:12 +01:00
Girish Ramakrishnan e76d4b3474 tests: fix app passwords test 2026-02-12 19:57:34 +01:00
Girish Ramakrishnan 88a44ee065 oidc: add alg to the jwks keys 2026-02-12 19:42:00 +01:00
Girish Ramakrishnan 51e02da277 spaces: add missing regions 2026-02-12 17:15:08 +01:00
Girish Ramakrishnan e9c3e42aa6 appPassword: add expiry 2026-02-12 16:23:31 +01:00
Girish Ramakrishnan 93a0063941 backups: add stop_integrity_check route 2026-02-09 22:00:40 +01:00
Girish Ramakrishnan 26a3cf79c5 backup: show last integrity in info dialog 2026-02-09 21:46:04 +01:00
Johannes Zellner 26999afc22 Reduce SaveIndicator timeout to 1500ms 2026-02-09 20:43:44 +01:00
Johannes Zellner 81729e4b2a Add SaveIndicator to email settings 2026-02-09 20:43:34 +01:00
Johannes Zellner 4bae5ee2fb Add SaveIndicator to user directory settings 2026-02-09 20:34:18 +01:00
Johannes Zellner a786e6c8f5 Do not crash if we have a new user without a username yet 2026-02-09 20:34:05 +01:00
Johannes Zellner 3803f36aa5 Fix language change to han chinese 2026-02-09 20:27:12 +01:00
Johannes Zellner 55bc26bd09 Add error state for SaveIndicator and put it in more places 2026-02-09 20:25:13 +01:00
Girish Ramakrishnan d84037a0dd add to changes 2026-02-09 18:24:56 +01:00
Girish Ramakrishnan 1ce5fcafd9 apppassword: display error 2026-02-09 17:52:40 +01:00
Johannes Zellner 281233f48b Unset align-items for section header toolbar to make buttons fill whole height 2026-02-09 17:29:12 +01:00
Johannes Zellner 68d73e088d Move notification settings to notifications view 2026-02-09 17:06:17 +01:00
Girish Ramakrishnan b433191b35 community: skip revoked versions 2026-02-09 16:11:55 +01:00
Girish Ramakrishnan d75ad44315 mail: fix haraka crash 2026-02-09 16:04:13 +01:00
Girish Ramakrishnan c3d3c3a6e9 app: if repo changes, do not autoupdate 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan b9b8ccb8ae community: stable/unstable 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan 5a56a7c8af community: show packager info 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan d4efb63f3d Update packages 2026-02-09 15:51:47 +01:00
Johannes Zellner 2ec349e919 Remove duplicate code 2026-02-09 15:33:10 +01:00
Johannes Zellner 772770273a Set tls provider upon dns provider change, not in a watcher, which has side-effects 2026-02-09 14:32:02 +01:00
Girish Ramakrishnan fa5cbfc304 mail: allow comma in email display name 2026-02-09 11:01:47 +01:00
Girish Ramakrishnan 5276321ade integrity: add integrity check fields and initial UI 2026-02-08 23:26:57 +01:00
Girish Ramakrishnan 6303602323 graphite: update go-carbon to 0.19.1 2026-02-07 12:01:25 +01:00
Girish Ramakrishnan 486fb0d10a collectd is gone 2026-02-07 11:45:12 +01:00
Girish Ramakrishnan 74b8a08251 add community app warning 2026-02-06 22:49:08 +01:00
Girish Ramakrishnan 2a244bb8d4 Update versions format to have a root 2026-02-06 22:39:29 +01:00
Girish Ramakrishnan 84e73943f7 typo 2026-02-06 22:34:42 +01:00
Girish Ramakrishnan ace09ca5a7 add versions url in the menu 2026-02-06 19:19:04 +01:00
Girish Ramakrishnan a9ae34b149 community: download iconUrl
also rename existing db field appStoreIcon to packageIcon
2026-02-06 19:13:55 +01:00
Girish Ramakrishnan cff778fe6a set fallback image url 2026-02-06 19:08:23 +01:00
Girish Ramakrishnan be69f9f8a3 minor package updates 2026-02-06 18:23:30 +01:00
Girish Ramakrishnan 5ca2078461 add iconUrl to manifest 2026-02-06 18:04:47 +01:00
Girish Ramakrishnan 4461e7225f validate versions file 2026-02-06 17:35:59 +01:00
Girish Ramakrishnan 49d5d10d77 typo 2026-02-06 16:17:22 +01:00
Girish Ramakrishnan 84374f03e9 cloudron-setup: fix the echo 2026-02-06 12:10:09 +01:00
Girish Ramakrishnan f8a44014f7 installer: check for process instead of locks 2026-02-06 12:02:46 +01:00
Girish Ramakrishnan 6befb64691 cloudron-setup: wait for apt setup 2026-02-06 12:01:14 +01:00
Girish Ramakrishnan 1ff2c21c61 Move custom app actions to a dropdown 2026-02-06 10:32:29 +01:00
Girish Ramakrishnan c79d4a24c4 community: allow version in the url input 2026-02-06 10:19:13 +01:00
Girish Ramakrishnan 3d7a5676d8 lint 2026-02-05 23:22:26 +01:00
Girish Ramakrishnan aa362477e8 community: validate the url in the dialog 2026-02-05 22:40:37 +01:00
Johannes Zellner 13b524e8a5 Fix bold/strong font weight in markdown 2026-02-05 21:57:20 +01:00
Girish Ramakrishnan d6eb6d3e3e community: store versionsUrl in the database 2026-02-05 19:32:29 +01:00
Girish Ramakrishnan 91b8f1a457 oidc: do not fail on notification failure 2026-02-05 18:26:14 +01:00
Johannes Zellner c8cdcfc99f Fix const usage of reactive() variable 2026-02-05 15:14:15 +01:00
Johannes Zellner fe20e738cd Update vite and vue related dependencies 2026-02-05 15:11:09 +01:00
Johannes Zellner e23856bf10 Fixup fonts 2026-02-05 15:08:33 +01:00
Girish Ramakrishnan a7de7fb286 initial implementation of community packages 2026-02-05 14:21:50 +01:00
Johannes Zellner a931d2a91f Attempt to improve fonts on chrome 2026-02-05 13:33:37 +01:00
Girish Ramakrishnan c94c66b71e do not use displayName since it may not be unique 2026-02-04 18:32:45 +01:00
Girish Ramakrishnan cb89c30591 Fix help url 2026-02-04 18:16:38 +01:00
Elias Hackradt 1012c0f654 Fix Applink ACL 2026-02-04 16:57:51 +00:00
Girish Ramakrishnan 9b5fb9ae8f applink: do not send label when not set 2026-02-04 14:53:50 +01:00
Girish Ramakrishnan c4055271a8 render email as plain text in checklist and postinstall 2026-02-04 14:45:50 +01:00
Johannes Zellner cd1df37ed3 Show save indicator for language and timezone submission 2026-02-04 12:43:58 +01:00
Johannes Zellner 3d8d4fd921 Show error overlay with link to services view if filemanager addon is down 2026-02-04 12:00:03 +01:00
Girish Ramakrishnan 17b0c3e48d services: Graphite -> Metrics 2026-02-04 11:33:40 +01:00
Johannes Zellner f364257db9 Do not send username for profile updates if it isn't set yet 2026-02-03 19:57:23 +01:00
Johannes Zellner 6b0d9f8551 Fix vue warning about wrong props type 2026-02-03 18:47:17 +01:00
Johannes Zellner f0fb420a8d Skip checklist db migration script if no owner exists 2026-02-03 18:41:39 +01:00
Johannes Zellner 8aa2695263 Avoid horizontal scrolling in volumes table 2026-02-03 18:32:11 +01:00
Johannes Zellner b9af8ee6be Send the service name when listing services 2026-02-03 18:32:11 +01:00
Girish Ramakrishnan 7077289840 Fix doc link 2026-02-03 18:26:15 +01:00
Johannes Zellner bdd35fb02a Go to app configure view after installation started 2026-02-03 11:30:35 +01:00
Girish Ramakrishnan 47660c5679 Update translations 2026-02-03 08:53:00 +01:00
Johannes Zellner 28573f9676 Right align table headers for backup content info 2026-02-02 16:41:47 +01:00
Johannes Zellner 375b7f6dd7 Let appstore filter bar wrap for mobile 2026-02-02 16:32:58 +01:00
Johannes Zellner 99ec2d5ce7 Use Section component for NotificationView to work better on mobile 2026-02-02 16:31:25 +01:00
Johannes Zellner f2afd654f8 Bring back reboot button in reboot required notification 2026-02-02 16:21:31 +01:00
Johannes Zellner d42919285b Add user filter for roles and invited status 2026-02-02 12:25:09 +01:00
Johannes Zellner 33a1f135e0 Hide quickactions on mobile 2026-02-02 11:41:50 +01:00
Johannes Zellner 214b836d13 Hide user roles on mobile 2026-02-02 11:36:19 +01:00
Johannes Zellner 408a07e8b9 Update pankow for tooltip fix 2026-02-02 10:41:02 +01:00
Girish Ramakrishnan b247731062 doc links have changed 2026-02-02 10:19:19 +01:00
Johannes Zellner dcaa484929 Show user roles always as separate column 2026-02-02 10:11:15 +01:00
Girish Ramakrishnan 35886633e5 appinstall: align the header text and icon 2026-02-02 06:40:43 +01:00
Johannes Zellner d04afc26e7 Add db migration to ensure done checklist items have an owner and timestamp 2026-02-01 13:57:55 +01:00
Johannes Zellner bb5f1b703e Update lock file 2026-02-01 13:20:10 +01:00
Girish Ramakrishnan dfe2d27709 comment out some logs 2026-01-31 12:09:55 +01:00
Girish Ramakrishnan 1dec4f0070 cloudron-support: try ipv4 and ipv6 image pull 2026-01-30 17:20:35 +01:00
Girish Ramakrishnan 89b6513217 installer: need at least ubuntu 22 2026-01-30 09:55:53 +01:00
Girish Ramakrishnan 16a8caa8db installer: better log messages 2026-01-30 09:54:33 +01:00
Girish Ramakrishnan 1594d190eb update: skip backup site check when skipBackup is true 2026-01-30 09:52:52 +01:00
Girish Ramakrishnan 3333f70a64 9.0.18 changes
(cherry picked from commit fd881b4c61)
2026-01-29 17:38:55 +01:00
Johannes Zellner 5bd803e6b4 hetznercloud wants name instead of search_name for zones 2026-01-29 15:35:29 +01:00
Girish Ramakrishnan b5f5b096d4 ami: do not set domain provider by default 2026-01-29 14:20:53 +01:00
Girish Ramakrishnan dce05140bf ami: add instanceId input box 2026-01-29 13:50:27 +01:00
Girish Ramakrishnan 29d5ac94b2 activation: remove unused setupToken 2026-01-29 13:17:12 +01:00
Girish Ramakrishnan 2b80c6c1ad setup: setupToken is not used anymore 2026-01-29 13:15:09 +01:00
Girish Ramakrishnan 94a62b040b setup: set initial value for tls config 2026-01-29 13:13:35 +01:00
Johannes Zellner 2bb9c50db9 Condense info in app install dialog 2026-01-29 11:58:09 +01:00
Girish Ramakrishnan 6533ba4581 appstore: include provider as part of state 2026-01-29 11:46:57 +01:00
Girish Ramakrishnan 081909572e Fix casing 2026-01-28 15:52:15 +01:00
Johannes Zellner aa84cb0079 Actually make multiplart also optional 2026-01-28 14:17:22 +01:00
Girish Ramakrishnan a66c3700b3 Fix doc urls 2026-01-28 12:07:04 +01:00
Johannes Zellner 70476bd168 Use jsonOrMultipart instead of jsonOptional and multipart 2026-01-27 22:01:18 +01:00
Johannes Zellner a7929e142f Build local image for updates in apptask 2026-01-27 22:01:18 +01:00
Johannes Zellner fd0d65b8ce Keep the app source archive with the app instance data dir 2026-01-27 22:01:18 +01:00
Johannes Zellner ef2a94c2c8 use local/id:version-ts as docker image tag for locally built apps 2026-01-27 22:01:18 +01:00
Johannes Zellner b43daf2f08 Use the uploaded app source tarball to build a local docker image 2026-01-27 22:01:18 +01:00
Johannes Zellner 280f628746 Accept json body or formdata in app install route 2026-01-27 22:01:18 +01:00
Johannes Zellner 713774c03f Attempt to parse formdata fields as json 2026-01-27 22:01:18 +01:00
Johannes Zellner 0889c1531e Optionally allow json middleware to not check the content-type 2026-01-27 22:01:18 +01:00
Girish Ramakrishnan dbd5810a08 Add to changes 2026-01-26 22:52:57 +01:00
Johannes Zellner c8722e9945 Update translations 2026-01-23 23:34:41 +01:00
Girish Ramakrishnan 87780a2fc8 sftp: update modules 2026-01-23 16:52:52 +01:00
Johannes Zellner ab03256db0 Update translations 2026-01-23 15:00:17 +01:00
Johannes Zellner e26640c80e Use translations in notification view 2026-01-23 15:00:08 +01:00
Johannes Zellner e6806453e1 Add pagination to NotificationsView 2026-01-23 14:24:31 +01:00
Johannes Zellner d0fb2583a5 Show unread notifications by default and purge them on read 2026-01-23 14:08:46 +01:00
Johannes Zellner c4f8f318af Add unread/showall filter for notifications 2026-01-23 13:57:15 +01:00
Girish Ramakrishnan a6286bb67e mongodb: fcv fix
have to upgrade mongodb to 8.0 first
2026-01-23 13:48:01 +01:00
Johannes Zellner 90aea9708c Update lock file 2026-01-23 13:03:41 +01:00
Johannes Zellner cb076123b3 Only run quickaction action if set 2026-01-23 13:03:12 +01:00
Johannes Zellner 70a9a66ae9 Do not order notifcations by check/unchecked 2026-01-23 13:03:12 +01:00
Girish Ramakrishnan 8521a47cfa mysql: pipework update 2026-01-23 12:32:47 +01:00
Johannes Zellner 106cc5238e Keep unread notifications in sync with headerbar 2026-01-23 12:26:12 +01:00
Girish Ramakrishnan 2040eb22a2 mail: update modules 2026-01-23 12:14:33 +01:00
Girish Ramakrishnan b6075a9765 redis: update to 8.4.0 2026-01-23 11:34:41 +01:00
Girish Ramakrishnan daacbcb89d mongodb: update to 8.2.3 2026-01-23 11:26:30 +01:00
Girish Ramakrishnan 6d622bbd14 postgres: pipework update 2026-01-23 11:18:06 +01:00
Girish Ramakrishnan f355da4874 Fix up changelog 2026-01-23 10:03:25 +01:00
Girish Ramakrishnan 4b36de5200 Update docker to 29.1.5 2026-01-23 10:02:57 +01:00
Girish Ramakrishnan 88d37e99aa docker: compare debian package version instead of docker version
this way after an ubuntu upgrade, we update it with the correct
docker package on the next cloudron upgrade
2026-01-23 09:30:44 +01:00
Girish Ramakrishnan 1608fc3fdc Update packages 2026-01-23 09:13:11 +01:00
Girish Ramakrishnan 057fd18139 Update nodejs to 24.13.0 2026-01-23 09:04:57 +01:00
Johannes Zellner b6371a0bdf Add standalone NotificationsView 2026-01-22 18:56:06 +01:00
Girish Ramakrishnan 03fe72e0b1 services: fix usage of pipework 2026-01-22 18:09:41 +01:00
Johannes Zellner 3bf4bddc10 Remove very old ubuntu 16 and 18 from cloudron-setup 2026-01-22 12:23:45 +01:00
Johannes Zellner 92dcf19511 Fix form submission for app clone dialog 2026-01-22 10:26:13 +01:00
Girish Ramakrishnan b238443a9d services: switch to using @cloudron/pipework
also improve the error messages along the way
2026-01-21 21:32:02 +01:00
Girish Ramakrishnan 021a39a964 services: destroy the read/write stream on error 2026-01-21 17:23:27 +01:00
Girish Ramakrishnan 72c494e9dc backuptask: fix usage of upload API 2026-01-21 09:48:21 +01:00
Girish Ramakrishnan 42cefd56eb Fix uploader API to handle write stream errors
When the upload is aborted/abandoed because the source file is missing,
the write stream is never destroyed. This dangling write stream can
later error and cause a crash.

Instead, create the write stream on demand and nearer to pipeline() to
make sure we do 'error' handling.
2026-01-20 22:26:08 +01:00
Johannes Zellner 944f163882 Fix QuickAction width in app backup view 2026-01-20 18:08:35 +01:00
Girish Ramakrishnan 11a8a73723 app install: Fix status of install button 2026-01-20 17:49:35 +01:00
Girish Ramakrishnan e34cf8f6a6 location: fix up form validation 2026-01-20 17:23:08 +01:00
Girish Ramakrishnan 7f8143f06f services: set some width to avoid column shifting
(cherry picked from commit 529d227e74)
2026-01-18 18:27:51 +01:00
Girish Ramakrishnan 472e513a9f postgresql: reindex fix
(cherry picked from commit 6b56efcf14)
2026-01-18 18:27:44 +01:00
Girish Ramakrishnan 1cbacab3a2 services: keep quick actions consistent
(cherry picked from commit f23c8a9243)
2026-01-18 18:27:38 +01:00
Girish Ramakrishnan 49bbb8588d 9.0.17 changes
(cherry picked from commit 98660567e5)
2026-01-18 11:48:30 +01:00
Girish Ramakrishnan 23e0fe5791 postgres: fix hook that upgrades vectorchord 2026-01-18 11:36:48 +01:00
Girish Ramakrishnan 6877dfb772 acme: ARI support
ARI is a hint from the cert issuer about when to renew a cert. We will
use it when the API is available.

It provides a mechanim for CAs to revoke certs and signal to clients
that cert should be renewed.

https://www.rfc-editor.org/rfc/rfc9773.txt
https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients
2026-01-17 23:28:47 +01:00
Girish Ramakrishnan f65b33f3fc Fix key type typo 2026-01-17 22:20:39 +01:00
Girish Ramakrishnan 3daddf2fe6 update superagent 2026-01-17 22:18:27 +01:00
Girish Ramakrishnan efccf2729b start moving openssl commands into openssl.js 2026-01-17 15:28:44 +01:00
Girish Ramakrishnan 3a1cd8f67f acme2: do not rely on db 2026-01-17 13:21:50 +01:00
Girish Ramakrishnan 53c90429d3 acme: rename variable 2026-01-17 10:26:04 +01:00
Girish Ramakrishnan 7b5384a7d5 acme2: add profile support
this adds acme profile support which was GA today.

https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability
https://letsencrypt.org/docs/profiles/
2026-01-17 09:51:29 +01:00
Girish Ramakrishnan 2b362d8eaf users: add note about invitationToken
this is a one time token that is valid until the account is set up.
this is the reason it has no expiry time.
2026-01-17 09:44:43 +01:00
Girish Ramakrishnan ce0024a43c Update translations 2026-01-16 15:07:52 +01:00
Girish Ramakrishnan 888696975d diskusage: hide last updated when refreshing 2026-01-16 15:07:48 +01:00
430 changed files with 27622 additions and 22078 deletions
+64
View File
@@ -3122,3 +3122,67 @@
[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
+44 -50
View File
@@ -1,17 +1,18 @@
#!/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 oidcServer from './src/oidcserver.js';
import paths from './src/paths.js';
import proxyAuth from './src/proxyauth.js';
import safe from '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;
@@ -54,50 +55,43 @@ 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 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 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.' }));
+51 -11
View File
@@ -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>
+5
View File
@@ -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}"
+2
View File
@@ -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 -1
View File
@@ -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>
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Confirm Device</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
h1, h1+p { font-weight: 100; text-align: center; }
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
h1 { font-size: 2.3em; }
code { font-size: 2em; }
button[autofocus] { width: 100%; display: block; margin-bottom: 10px; font-size: 14px; font-weight: 700; height: 36px; padding: 0 8px; border: 0; color: #fff; background-color: #4d90fe; cursor: pointer; }
button[autofocus]:hover { background-color: #357ae8; }
button[name=abort] { background: none; border: none; padding: 0; font: inherit; cursor: pointer; color: #666; opacity: .6; }
.help { width: 100%; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>Confirm Device</h1>
<p>
<strong><%= clientName %></strong>
<br/><br/>
The following code should be displayed on your device<br/><br/>
<code><%= userCode %></code>
<br/><br/>
<small>If you did not initiate this action or the code does not match, please close this window or click abort.</small>
</p>
<%- form %>
<button autofocus type="submit" form="op.deviceConfirmForm">Continue</button>
<div class="help">
<button type="submit" form="op.deviceConfirmForm" value="yes" name="abort">[ Abort ]</button>
</div>
</div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign-in</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
h1, h1+p { font-weight: 100; text-align: center; }
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
h1 { font-size: 2.3em; }
p.red { color: #d50000; }
input[type=text] { height: 44px; font-size: 16px; width: 100%; margin-bottom: 10px; background: #fff; border: 1px solid #d9d9d9; border-top: 1px solid silver; padding: 0 8px; box-sizing: border-box; text-transform: uppercase; text-align: center; }
input[type=text]::placeholder { text-transform: none; }
[type=submit] { width: 100%; display: block; margin-bottom: 10px; text-align: center; font-size: 14px; font-weight: 700; height: 36px; padding: 0 8px; border: 0; color: #fff; background-color: #4d90fe; cursor: pointer; }
[type=submit]:hover { background-color: #357ae8; }
</style>
</head>
<body>
<div class="container">
<h1>Sign-in</h1>
<%- message %>
<%- form %>
<button type="submit" form="op.deviceInputForm">Continue</button>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Success</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
h1, h1+p { font-weight: 100; text-align: center; }
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
h1 { font-size: 2.3em; }
</style>
</head>
<body>
<div class="container">
<h1>Success</h1>
<p>Your device has been authorized. You can close this window.</p>
</div>
</body>
</html>
+2
View File
@@ -9,6 +9,8 @@
name: name,
note: note,
submitUrl: submitUrl,
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
passkeyLoginUrl: passkeyLoginUrl,
footer: footer,
language: language
}) %>;
+1606 -2637
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -7,26 +7,27 @@
},
"type": "module",
"dependencies": {
"@cloudron/pankow": "^3.6.4",
"@simplewebauthn/browser": "^13.3.0",
"@cloudron/pankow": "^4.1.5",
"@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.0.3",
"eslint-plugin-vue": "^10.8.0",
"marked": "^17.0.4",
"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"
"vite": "^8.0.0",
"vite-plugin-singlefile": "^2.3.2",
"vue": "^3.5.30",
"vue-i18n": "^11.3.0",
"vue-router": "^5.0.3"
}
}
File diff suppressed because it is too large Load Diff
+1 -11
View File
@@ -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.",
+118 -80
View File
@@ -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"
},
@@ -1405,8 +1427,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 +1449,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 +1484,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 +1503,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 +1513,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 +1540,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 +1564,7 @@
"localDirectory": "Lokales Verzeichnis",
"remountActionTooltip": "Neu einhängen",
"editVolumeDialog": {
"title": "Datenträger {{ name }} konfigurieren"
"title": "Datenträger konfigurieren"
},
"emptyPlaceholder": "Keine Datenträger"
},
@@ -1551,7 +1577,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 +1586,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 +1607,10 @@
"clients": {
"title": "OpenID-Clients",
"empty": "Keine OpenID-Clients"
},
"clientCredentials": {
"title": "Client Zugangsdaten",
"description": "Zugangsdaten des Clients \"{{ clientName }}\" kopieren"
}
},
"userdirectory": {
@@ -1593,22 +1624,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 +1651,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": {
+100 -33
View File
@@ -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 doesnt 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",
@@ -831,7 +875,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 +911,18 @@
"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"
},
"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 +946,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 +968,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 }})",
@@ -1306,14 +1359,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 +1410,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 +1521,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 +1541,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",
@@ -1639,7 +1701,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 +1741,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."
}
}
+32 -29
View File
@@ -6,10 +6,10 @@
"users": "Usuarios",
"errorUserManagementSelectAtLeastOne": "Selecciona al menos un usuario o un grupo",
"userManagementSelectUsers": "Permitir solo a los siguientes usuarios y grupos",
"userManagementAllUsers": "Permitir a todos los usuarios de este Cloudron",
"userManagementAllUsers": "Permitir a todos los usuarios en este Cloudron",
"userManagementLeaveToApp": "Deja la gestión de usuarios a la aplicación",
"userManagementMailbox": "Todos los usuarios con un buzón en este Cloudron tienen acceso.",
"userManagementNone": "Esta aplicación tiene su propia gestión de usuarios. Esta configuración determina si esta aplicación está visible en el panel del usuario.",
"userManagementMailbox": "Los usuarios con un <a href=\"/#/mailboxes\">buzón de correo</a> pueden iniciar sesión con el correo electrónico de su buzón y la contraseña de Cloudron.",
"userManagementNone": "Esta aplicación tiene su propia gestión de usuarios.",
"userManagement": "Gestión de usuarios",
"manualWarning": "Configurar manualmente los registros DNS A (IPv4) y AAAA (IPv6) para <b>{{ location }}</b> que apuntan a este servidor",
"locationPlaceholder": "Dejar vacío para usar solo el dominio",
@@ -31,13 +31,16 @@
"appNotFoundDialog": {
"description": "No hay aplicación <b>{{ appId }}</b> con versión <b>{{ version }}</b>.",
"title": "Aplicación no encontrada"
},
"action": {
"addCustomApp": "Añadir Aplicación personalizada"
}
},
"main": {
"rebootDialog": {
"rebootAction": "Reiniciar ahora",
"description": "Use esto para aplicar actualizaciones de seguridad o si experimenta un comportamiento inesperado. Todas las aplicaciones y servicios que se ejecutan actualmente en este Cloudron se iniciarán automáticamente cuando se complete el reinicio.",
"title": "¿Realmente quieres reiniciar el servidor?"
"description": "Todas las aplicaciones y servicios se reiniciarán automáticamente.<br/><br/>¿Reiniciar el servidor ahora?",
"title": "Reiniciar el servidor"
},
"action": {
"logs": "Registros",
@@ -45,10 +48,15 @@
"remove": "Borrar",
"edit": "Editar",
"add": "Añadir",
"next": "Siguiente"
"next": "Siguiente",
"configure": "Configurar",
"restart": "Reanudar",
"reset": "Reiniciar",
"loadMore": "Cargar más"
},
"table": {
"version": "Versión"
"version": "Versión",
"created": "Creado"
},
"actions": "Acciones",
"displayName": "Nombre para mostrar",
@@ -75,7 +83,13 @@
"groups": "Grupos"
},
"statusEnabled": "Habilitado",
"loadingPlaceholder": "Cargando"
"loadingPlaceholder": "Cargando",
"platform": {
"startupFailed": "El inicio de la plataforma falló"
},
"sidebar": {
"collapseAction": "Contraer la barra lateral"
}
},
"apps": {
"searchPlaceholder": "Busca Aplicaciones",
@@ -84,7 +98,7 @@
"title": "Todavía no tienes acceso a ninguna aplicación."
},
"noApps": {
"description": "¿Qué te parece si instalas algunas? Echa un vistazo a la <a href=\"{{ appStoreLink }}\"> Tienda de Aplicaciones</a>",
"description": "¿Qué tal si instalas algunas? Visita la <a href=\"{{ appStoreLink }}\">App Store</a>.",
"title": "¡No hay aplicaciones instaladas todavía!"
},
"title": "Mis Aplicaciones",
@@ -105,22 +119,22 @@
"externalLdap": {
"errorSelfSignedCert": "El servidor está utilizando un certificado no válido o autofirmado.",
"bindUsername": "Enlazar DN/Nombre de usuario (opcional)",
"bindPassword": "Enlazar Contraseña (opcional)",
"bindPassword": "Vincular contraseña (opcional)",
"groupBaseDn": "Grupo Base DN",
"baseDn": "DN Base",
"configureAction": "Configurar",
"syncAction": "Sincronizar",
"syncAction": "Sincronizar ahora",
"autocreateUsersOnLogin": "Crear usuarios automáticamente al iniciar sesión",
"groupnameField": "Campo de Nombre de Grupo",
"groupFilter": "Filtro de Grupo",
"syncGroups": "Sincronizar Grupos",
"syncGroups": "Sincronizar grupos",
"usernameField": "Campo de Nombre de Usuario",
"filter": "Filtro",
"acceptSelfSignedCert": "Aceptar Certificado Autofirmado",
"acceptSelfSignedCert": "Aceptar certificado autofirmado",
"server": "URL del Servidor",
"provider": "Proveedor",
"noopInfo": "La autentificación LDAP no está configurada.",
"description": "Esta configuración sincroniza y autentifica usuarios y grupos desde un servidor LDAP o Active Directory externo. La sincronización se ejecuta periódicamente pero también se puede activar manualmente.",
"noopInfo": "No hay ningún directorio externo configurado",
"description": "Sincroniza y autentifica usuarios y grupos desde un servidor LDAP o Active Directory externo. La sincronización se ejecuta periódicamente cada 4 horas.",
"title": "Conectar un directorio externo",
"auth": "Auth",
"disableWarning": "La fuente de autentificación de todos los usuarios existentes se restablecerá para autentificarse en la base de datos de contraseñas local."
@@ -200,11 +214,11 @@
"title": "Borrar Usuario {{ username }}"
},
"user": {
"activeCheckbox": "Usuario activo",
"activeCheckbox": "El usuario está activo",
"recoveryEmail": "Correo electrónico de recuperación de contraseña",
"primaryEmail": "Email Principal",
"displayName": "Nombre para mostrar",
"usernamePlaceholder": "Opcional. Si no se proporciona, el usuario puede elegirlo durante el registro",
"usernamePlaceholder": "Opcional. Si no se proporciona, el usuario puede elegirlo durante el registro.",
"noGroups": "No hay grupos disponibles.",
"groups": "Grupos",
"role": "Rol",
@@ -388,8 +402,6 @@
"useFileEncryption": "Se usa cifrado de archivos"
},
"profile": {
"enable2FAAction": "Habilita 2FA",
"disable2FAAction": "Deshabilita 2FA",
"changePasswordAction": "Cambiar contraseña",
"createApiToken": {
"copyNow": "Por favor copia el token API ahora. No se volverá a mostrar por motivos de seguridad.",
@@ -646,12 +658,9 @@
"title": "Ajustes",
"updateScheduleDialog": {
"description": "Establece los días y horarios para las actualizaciones automáticas de la plataforma y la aplicación. Asegúrate de que esta programación no coincida con la programación de las copias de seguridad.",
"hours": "Horas",
"days": "Días",
"selectOne": "Seleccione al menos un día y una hora",
"enableCheckbox": "Habilitar Actualizaciones Automáticas",
"disableCheckbox": "Desactivar las Actualizaciones Automáticas",
"title": "Configurar la programación de las Actualizaciones Automáticas"
"disableCheckbox": "Desactivar las Actualizaciones Automáticas"
},
"updates": {
"stopUpdateAction": "Parar Actualización",
@@ -660,7 +669,6 @@
"title": "Actualizaciones",
"description": "Las actualizaciones de la plataforma y de la aplicación se aplican según el cronograma establecido aquí, utilizando la <a href=\"/#/system-settings\">Zona horaria del sistema</a>.",
"disabled": "Deshabilitado",
"schedule": "Programar",
"onLatest": "el último"
},
"language": {
@@ -970,11 +978,6 @@
"uninstallAction": "Desinstalar",
"title": "Desinstalar",
"description": "Esto desinstalará la aplicación y eliminará sus datos. Las copias de seguridad se limpiarán según la política de copias de seguridad."
},
"startStop": {
"startAction": "Arrancar",
"stopAction": "Parar",
"description": "Las aplicaciones se pueden detener para conservar los recursos del servidor en lugar de desinstalarlas. Las futuras copias de seguridad de la aplicación no incluirán ningún cambio en la aplicación entre ahora y la copia de seguridad de la aplicación más reciente. Por este motivo, se recomienda activar una copia de seguridad antes de detener la aplicación."
}
},
"cloneDialog": {
+1 -11
View File
@@ -263,8 +263,6 @@
"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 }}"
}
@@ -517,12 +515,9 @@
},
"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",
@@ -714,11 +709,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": {
+105 -49
View File
@@ -30,7 +30,8 @@
"edit": "Edit"
},
"table": {
"version": "Versi"
"version": "Versi",
"created": "Dibuat"
},
"logout": "Keluar",
"action": {
@@ -42,7 +43,8 @@
"configure": "Konfigurasi",
"restart": "Mulai ulang",
"reset": "Atur Ulang",
"logs": "Log"
"logs": "Log",
"loadMore": "Muat lebih banyak"
},
"searchPlaceholder": "Cari",
"actions": "Tindakan",
@@ -87,7 +89,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 +105,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 +280,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 +301,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 +323,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 +358,11 @@
"removeAppPassword": {
"title": "Hapus Kata sandi Aplikasi",
"description": "Hapus kata sandi aplikasi \"{{ name }}\"?"
},
"twoFactorAuth": {
"title": "Autentikasi dua faktor",
"totpEnabled": "Menggunakan kata sandi sekali pakai berbasis waktu (TOTP)",
"passkeyEnabled": "Menggunakan passkey"
}
},
"backups": {
@@ -362,15 +376,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 +515,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 +724,13 @@
"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"
},
"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": {
@@ -791,7 +807,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 +867,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 +908,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 +930,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 }})",
@@ -1163,7 +1184,7 @@
},
"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 +1198,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 +1303,7 @@
"cron": {
"title": "Crontab",
"saveAction": "Simpan",
"addCommonPattern": "Tambahkan pola umum",
"addCommonPattern": "Masukkan pola umum",
"commonPattern": {
"everyMinute": "Setiap Menit",
"everyHour": "Setiap Jam",
@@ -1334,13 +1355,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 +1388,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 +1413,7 @@
},
"import": {
"title": "Impor",
"description": "Impor aplikasi dari cadangan eksternal."
"description": "Impor aplikasi dari cadangan eksternal"
},
"auto": {
"title": "Cadangan otomatis",
@@ -1401,11 +1439,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 +1483,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 +1616,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 +1628,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": {
@@ -1621,7 +1667,12 @@
"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 +1687,8 @@
"resetPasswordAction": "Atur ulang kata sandi",
"errorIncorrect2FAToken": "Token 2FA tidak valid",
"errorInternal": "Terjadi kesalahan internal, coba lagi nanti",
"loginAction": "Masuk"
"loginAction": "Masuk",
"usePasskeyAction": "Gunakan passkey"
},
"passwordReset": {
"title": "Pengaturan ulang kata sandi",
@@ -1658,5 +1710,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."
}
}
+1 -11
View File
@@ -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",
+65 -29
View File
@@ -46,7 +46,8 @@
"next": "Volgende",
"configure": "Configureer",
"restart": "Herstart",
"reset": "Reset"
"reset": "Reset",
"loadMore": "Laad meer"
},
"rebootDialog": {
"title": "Herstart Server",
@@ -104,6 +105,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 +291,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 +336,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 +348,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 +358,11 @@
"removeAppPassword": {
"title": "Verwijder app-wachtwoord",
"description": "Verwijder App-wachtwoord \"{{ name }}\"?"
},
"twoFactorAuth": {
"title": "Twee-Factor (2FA) authenticatie",
"totpEnabled": "Gebruikt tijdgebaseerd eenmalige wachtwoord (TOTP)",
"passkeyEnabled": "Gebruikt passkey"
}
},
"backups": {
@@ -381,7 +394,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",
@@ -499,7 +515,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",
@@ -653,7 +671,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",
@@ -666,7 +686,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",
@@ -855,14 +877,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": {
@@ -905,11 +928,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",
@@ -1023,6 +1041,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": {
@@ -1115,16 +1143,12 @@
"stopUpdateAction": "Stop update",
"description": "Platform en app updates worden toegepast met de geconfigureerde planning met deze <a href=\"/#/system-locale\">Systeem tijdzone</a>.",
"disabled": "Uitgeschakeld",
"schedule": "Update planning",
"onLatest": "Laatste"
},
"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": {
@@ -1207,7 +1231,12 @@
"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",
@@ -1231,11 +1260,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": {
@@ -1253,15 +1282,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": {
@@ -1482,7 +1512,8 @@
"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"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1639,7 +1670,8 @@
"title": "Backup Locaties",
"emptyPlaceholder": "Geen backup locaties",
"lastRun": "Laatste uitvoering",
"description": "Backuplocaties geven aan waar systeem- en app-backups worden opgeslagen. App-backups kunnen afzonderlijk worden hersteld."
"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": {
@@ -1678,5 +1710,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."
}
}
-5
View File
@@ -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"
+100 -44
View File
@@ -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": "Автоматические резервные копии",
@@ -416,10 +431,10 @@
},
"operators": {
"title": "Операторы",
"description": "Операторы могут настраивать и поддерживать работу этого приложения."
"description": "Настроить, кто может поддерживать работу приложения"
},
"userManagement": {
"description": "Настроить, кто может входить и использовать это приложение.",
"description": "Настроить, кто может входить и использовать это приложение",
"descriptionSftp": "Данный параметр также контролирует доступ к SFTP.",
"dashboardVisibility": "Видимость в панели управления",
"visibleForAllUsers": "Отображать для всех пользователей Cloudron",
@@ -503,7 +518,7 @@
"hourly": "Каждый час",
"service": "Проверить (один запуск)"
},
"addCommonPattern": "Добавить общий шаблон",
"addCommonPattern": "Вставить общий шаблон",
"description": "Задания Cron, требуемые для правильной работы приложения, уже интегрированы в контейнер. Здесь можно настроить прочие задания."
},
"display": {
@@ -553,11 +568,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 +611,6 @@
}
},
"uninstall": {
"startStop": {
"description": "Вместо удаления, приложение может быть остановлено для освобождения ресурсов сервера. Будущие резервные копии не сохранят текущее состояние приложения до момента остановки. Рекомендуется запустить процесс резервного копирования вручную до остановки работы приложения.",
"startAction": "Запустить",
"stopAction": "Остановить"
},
"uninstall": {
"title": "Удаление",
"description": "Удалить приложение и все его данные. Резервные копии очищаются в соответствии с политикой резервного копирования.",
@@ -627,7 +653,7 @@
"cloneDialog": {
"title": "Клонировать приложение",
"location": "Расположение",
"description": "Клон использует резервную копию версии <b>v{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
"description": "Клон использует резервную копию версии <b>{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
},
"addApplinkDialog": {
"title": "Добавить Внешнюю ссылку"
@@ -670,6 +696,16 @@
"forumAction": "Форум",
"appLink": {
"title": "Внешняя ссылка"
},
"start": {
"title": "Старт",
"description": "Запустить приложение и сделать его снова доступным.",
"action": "Старт"
},
"stop": {
"action": "Стоп",
"title": "Стоп",
"description": "Остановить приложение, чтобы сохранить ресурсы. Создайте резервную копию перед этим, чтобы сохранить последние изменения."
}
},
"backups": {
@@ -686,7 +722,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 +809,14 @@
"title": "Резервные копии",
"backupDetails": {
"title": "Детали резервного копирования",
"id": "Id",
"date": "Дата",
"version": "Версия",
"id": "ID Резервной копии",
"date": "Создано",
"version": "Версия пакета",
"size": "Размер",
"duration": "Продолжительность"
"duration": "Продолжительность резервного копирования",
"lastIntegrityCheck": "Последняя проверка целостности",
"integrityNever": "никогда",
"integrityInProgress": "В процессе"
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -818,7 +858,9 @@
"title": "Настроить содержание резервной копии"
},
"useFileAndFileNameEncryption": "Используется шифрование файлов и их имён",
"useFileEncryption": "Используется шифрование файлов"
"useFileEncryption": "Используется шифрование файлов",
"checkIntegrity": "Проверить целостность",
"stopIntegrity": "Остановить проверку целостности"
},
"branding": {
"title": "Брендирование",
@@ -1005,17 +1047,13 @@
"updateAvailableAction": "Доступно обновление",
"stopUpdateAction": "Остановить обновление",
"description": "Обновления платформы и приложений запускаются с учётом установленного расписания и в соответствии с <a href=\"/#/system-settings\">системным часовым поясом</a>.",
"schedule": "Расписание обновлений",
"disabled": "Выключено",
"onLatest": "последний"
},
"updateScheduleDialog": {
"title": "Настроить расписание автоматических обновлений",
"disableCheckbox": "Выключить автоматические обновления",
"enableCheckbox": "Включить автоматические обновления",
"selectOne": "Выберите по крайней мере один день и время",
"days": "Дни",
"hours": "Часы",
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с расписанием резервного копирования."
},
"updateDialog": {
@@ -1092,7 +1130,9 @@
"changeDashboardDomain": {
"title": "Домен панели управления",
"changeAction": "Изменить домен",
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена"
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена",
"confirmMessage": "Это действие сбросит ключи доступа для всех пользователей.",
"confirmTitle": "Вы точно хотите сменить домен панели управления?"
},
"domainDialog": {
"editTitle": "Редактировать домен",
@@ -1150,7 +1190,9 @@
"inwxUsername": "Имя пользователя INWX",
"inwxPassword": "Пароль INWX",
"customNameservers": "Домен использует пользовательские серверы имён (vanity)",
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен."
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен.",
"carddavLocation": "Расположение сервера CardDAV",
"caldavLocation": "Расположение сервера CalDAV"
},
"removeDialog": {
"title": "Удалить домен",
@@ -1189,7 +1231,12 @@
"allCaughtUp": "Уведомления отсутствуют",
"settingsDialog": {
"description": "Уведомления о выбранных событиях будут отправлены на основной Email."
}
},
"title": "Уведомления",
"showAll": "Все",
"showUnread": "Непрочитанные",
"markUnread": "Отметить как непрочитанные",
"markRead": "Отметить как прочитанные"
},
"logs": {
"title": "Логи",
@@ -1210,7 +1257,7 @@
"filemanager": {
"title": "Файловый менеджер",
"newDirectoryDialog": {
"title": "Имя новой папки",
"title": "Новая папка",
"create": "Создать"
},
"newFileDialog": {
@@ -1232,7 +1279,8 @@
"restartApp": "Перезагрузить приложение",
"uploadFolder": "Загрузить папку",
"openTerminal": "Открыть терминал",
"openLogs": "Открыть логи"
"openLogs": "Открыть логи",
"refresh": "Обновить"
},
"removeDialog": {
"reallyDelete": "Действительно удалить?"
@@ -1241,7 +1289,7 @@
"pasteInProgress": "Выполняется копирование / перемещение",
"deleteInProgress": "Выполняется удаление",
"chownDialog": {
"title": "Смена владельца",
"title": "Изменить владельца",
"newOwner": "Новый владелец",
"change": "Изменить владельца",
"recursiveCheckbox": "Изменить владельца рекурсивно"
@@ -1272,7 +1320,7 @@
"symlink": "Символическая ссылка на {{ target }}",
"menu": {
"rename": "Переименовать",
"chown": "Изменить владельца",
"chown": "Смена владельца",
"extract": "Распаковать здесь",
"download": "Скачать",
"delete": "Удалить",
@@ -1464,7 +1512,8 @@
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginAction": "Войти"
"loginAction": "Войти",
"usePasskeyAction": "Использовать ключ доступа"
},
"passwordReset": {
"title": "Сброс пароля",
@@ -1608,7 +1657,8 @@
"archives": {
"listing": {
"placeholder": "Архивные приложения отсутствуют"
}
},
"description": "В архивированном приложении сохраняется его последняя резервная копия. Эта копия хранится постоянно и может быть восстановлена в любой момент."
},
"backup": {
"target": {
@@ -1619,7 +1669,9 @@
"sites": {
"title": "Локации резервных копий",
"emptyPlaceholder": "Локации отсутствуют",
"lastRun": "Последний запуск"
"lastRun": "Последний запуск",
"description": "Локации резервных копий указывают на то, где будут сохраняться копии системы и приложений. Резервные копии приложений могут быть восстановлены по-отдельности.",
"noAutomaticUpdateBackupWarning": "Не настроено ни одной локации резервных копий для хранения копий автоматических обновлений. Включите \"Хранить бэкапы автоматических обновлений здесь\" по крайней мере в одной локации, чтобы активировать автоматические обновления."
},
"site": {
"removeDialog": {
@@ -1658,5 +1710,9 @@
},
"server": {
"title": "Сервер"
},
"communityapp": {
"installwarning": "Cloudron не проводит аудит приложений, созданных сообществом. Устанавливайте приложения только от проверенных разработчиков. Сторонний код может поставить под угрозу безопасности вашей системы.",
"unstablewarning": "Разработчик пометил это приложение как нестабильное."
}
}
+1 -12
View File
@@ -291,7 +291,6 @@
"access": "Truy cập API",
"allowedIpRanges": "Dãy IP cho phép"
},
"enable2FAAction": "Bật xác minh hai bước",
"primaryEmail": "Email chính",
"passwordRecoveryEmail": "Email khôi phục mật khẩu",
"appPasswords": {
@@ -324,7 +323,6 @@
"email": "Thêm email mới",
"password": "Xác nhận bằng mật khẩu"
},
"disable2FAAction": "Tắt xác minh hai bước",
"changeFallbackEmail": {
"title": "Đổi email khôi phục mật khẩu"
},
@@ -806,12 +804,9 @@
},
"updateScheduleDialog": {
"description": "Chọn ngày và thời gian mà Cloudron sẽ tự động cập nhật phiên bản mới của hệ thống và app. Xin tránh chọn trùng lịch cập nhật này với <a href=\"/#/backups\">lịch sao lưu</a>.",
"hours": "Thời gian",
"selectOne": "Xin chọn ít nhất một ngày và thời gian",
"days": "Ngày",
"enableCheckbox": "Bật chế độ cập nhật tự động",
"disableCheckbox": "Tắt chế độ cập nhật tự động",
"title": "Cấu hình lịch cập nhật tự động"
"disableCheckbox": "Tắt chế độ cập nhật tự động"
},
"updates": {
"checkForUpdatesAction": "Kiểm tra cập nhật",
@@ -819,7 +814,6 @@
"updateAvailableAction": "Có phiên bản cập nhật mới",
"title": "Cập nhật",
"disabled": "Đã tắt",
"schedule": "Lịch cập nhật",
"description": "Cập nhật Hệ thống và Ứng dụng được thực hiện tự động dựa trên Lịch cập nhật trong <a href=\"/#/settings\">Múi giờ hệ thống</a>."
},
"timezone": {
@@ -1078,11 +1072,6 @@
"uninstallAction": "Xoá",
"description": "Việc này sẽ gỡ cài đặt app và xóa tất cả dữ liệu trong app. Các bản sao lưu sẽ được dọn dẹp dựa trên chính sách sao lưu.",
"title": "Xoá"
},
"startStop": {
"stopAction": "Dừng",
"startAction": "Khởi động",
"description": "App có thể được dừng chạy để bảo tồn tài nguyên server thay vì xoá app. Những bản sao lưu tương lai sẽ không bao gồm những thay đổi từ thời điểm này đến bản sao lưu kề cận nhất. Vì lý do này, bạn nên tạo một bản sao lưu trước khi cho dừng app."
}
},
"repair": {
-10
View File
@@ -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": "卸载",
+34 -9
View File
@@ -5,12 +5,13 @@ 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 Headerbar from './components/Headerbar.vue';
@@ -34,6 +35,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 +66,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 +276,15 @@ fetcher.globalOptions.errorHook = (error) => {
const dashboardModel = DashboardModel.create();
const profileModel = ProfileModel.create();
const provisionModel = ProvisionModel.create();
const notificationModel = NotificationsModel.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 +325,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 +389,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 +399,12 @@ 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 onOnline() {
ready.value = true;
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
@@ -403,8 +420,10 @@ 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 +436,33 @@ 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();
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 +477,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" :subscription="subscription" :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 +505,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" />
+3 -3
View File
@@ -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" />
+3 -1
View File
@@ -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 }"/>
+4 -4
View File
@@ -185,19 +185,19 @@ onMounted(async () => {
<br/>
<TableView :columns="columns" :model="apiTokens" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
<template #lastUsedTime="apiToken">
<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>
+55 -66
View File
@@ -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 { 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,28 @@ 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);
async function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
if (location.value && !isValidDomain(location.value + '.' + domain.value)) 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 (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
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 +74,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
@@ -91,6 +99,8 @@ function onDomainChange() {
}
async function onSubmit(overwriteDns) {
if (!form.value.reportValidity()) return;
formError.value = {};
busy.value = true;
@@ -148,12 +158,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 +185,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,35 +211,15 @@ 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: [] };
@@ -246,8 +236,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 +249,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 +258,7 @@ defineExpose({
currentScreenshotPos = 0;
step.value = STEP.DETAILS;
dialog.value.open();
},
close() {
dialog.value.close();
@@ -283,15 +274,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">
@@ -309,15 +299,15 @@ defineExpose({
<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(false)" 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"/>
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :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>
@@ -328,21 +318,21 @@ defineExpose({
<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" />
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" 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"/>
<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 v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }}</Button>
</div>
</fieldset>
</form>
@@ -370,7 +360,6 @@ defineExpose({
.app-install-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
+70 -26
View File
@@ -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,30 @@ 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 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 +100,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 +111,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) {
@@ -160,7 +189,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 +199,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>
@@ -205,8 +243,14 @@ onMounted(async () => {
<br/>
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
<template #actions="password">
<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>
+10 -8
View File
@@ -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>
+66 -20
View File
@@ -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>
@@ -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'">
@@ -0,0 +1,90 @@
<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 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
}
});
</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" 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>
+17 -1
View File
@@ -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>
-6
View File
@@ -75,10 +75,4 @@ onMounted(async () => {
margin-left: 4px;
}
.disks-last-updated {
font-size: 12px;
font-weight: bold;
align-self: center;
}
</style>
+4 -3
View File
@@ -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>
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, watch } from 'vue';
import { ref } from 'vue';
import { TextInput, InputGroup, MaskedInput, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
import { ENDPOINTS_OVH } from '../constants.js';
import DomainsModel from '../models/DomainsModel.js';
@@ -58,7 +58,7 @@ function resetFields() {
dnsConfig.value.accessKey = '';
dnsConfig.value.accessToken = '';
dnsConfig.value.apiKey = '';
dnsConfig.value.apikey = '';
dnsConfig.value.appKey = '';
dnsConfig.value.appSecret = '';
dnsConfig.value.apiPassword = '';
dnsConfig.value.apiSecret = '';
@@ -77,7 +77,7 @@ function resetFields() {
dnsConfig.value.username = '';
}
watch(provider, (p) => {
function onProviderChange(p) {
resetFields();
// wildcard LE won't work without automated DNS
@@ -86,7 +86,7 @@ watch(provider, (p) => {
} else {
tlsProvider.value = 'letsencrypt-prod-wildcard';
}
}, { immediate: true });
}
const gcdnsFileParseError = ref('');
function onGcdnsFileInputChange(event) {
@@ -127,7 +127,7 @@ 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" :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>
@@ -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>
+277
View File
@@ -0,0 +1,277 @@
<script setup>
import { ref, reactive, 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 = reactive([]);
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.length) filter.actions = actions.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, 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: 160px;">{{ $t('eventlog.time') }}</th>
<th style="width: 15%;">{{ $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 style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
<td class="eventlog-source">{{ eventlog.source }}</td>
<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" 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;
}
.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>
+23 -1
View File
@@ -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>
+97 -59
View File
@@ -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);
}
+1 -1
View File
@@ -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;
}
+3 -118
View File
@@ -6,14 +6,11 @@ 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', 'subscription', 'notificationCount']);
const profile = inject('profile');
@@ -27,58 +24,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 +79,6 @@ function onAvatarClick(event) {
}
onMounted(async () => {
if (profile.value.isAtLeastAdmin) await refresh();
await trackPlatformStatus();
});
@@ -150,30 +93,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>
@@ -199,7 +118,7 @@ onUnmounted(() => {
<!-- Warnings if subscription is expired or unpaid -->
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
<div class="headerbar-action" 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,40 +168,6 @@ 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 {
background-color: var(--pankow-color-danger);
color: white;
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+6 -2
View File
@@ -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);
+28 -8
View File
@@ -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,18 +1,19 @@
<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);
@@ -26,6 +27,7 @@ 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');
@@ -34,10 +36,12 @@ async function onSubmit() {
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 +51,79 @@ 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;
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.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>
@@ -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>
-1
View File
@@ -79,7 +79,6 @@ onUnmounted(() => {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.section-header-title-text {
+2 -1
View File
@@ -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();
@@ -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;
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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';
+57 -7
View File
@@ -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">&nbsp;<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>
+45 -33
View File
@@ -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>
+5 -1
View File
@@ -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');
+4 -4
View File
@@ -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>
+27 -10
View File
@@ -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"/>
+2 -1
View File
@@ -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>
+80 -39
View File
@@ -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();
@@ -232,11 +240,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 +274,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 +322,10 @@ onMounted(async () => {
busy.value = false;
});
onUnmounted(() => {
if (integrityPollTimer) clearTimeout(integrityPollTimer);
});
</script>
<template>
@@ -374,7 +410,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 +423,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">&nbsp;<b>{{ backup.label }}</b></span>
<span>&nbsp;<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">&nbsp;<b>{{ item.label }}</b></span>
<span>&nbsp;<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>
+23 -130
View File
@@ -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>
+12 -6
View File
@@ -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">
+30 -28
View File
@@ -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';
@@ -13,7 +13,6 @@ const props = defineProps([ 'app' ]);
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const dashboardDomain = inject('dashboardDomain');
const domains = ref([]);
const busy = ref(false);
const errorMessage = ref('');
@@ -55,36 +54,36 @@ function onAddRedirect() {
});
}
const formValid = computed(() => {
if (!domain.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
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 *.
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 = {};
@@ -190,15 +189,17 @@ 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>
@@ -206,7 +207,7 @@ onMounted(async () => {
<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"/>
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" :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 -->
@@ -219,7 +220,7 @@ onMounted(async () => {
<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"/>
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :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>
@@ -271,11 +272,12 @@ onMounted(async () => {
<br/>
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
<br v-if="errorMessage"/>
<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">{{ $t('app.location.saveAction') }}</Button>
</div>
</template>
+2 -2
View File
@@ -176,7 +176,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 +225,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>
+58 -1
View File
@@ -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();
@@ -55,6 +55,47 @@ async function onArchive() {
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 +116,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>
+23 -14
View File
@@ -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/>
+114 -2
View File
@@ -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/
@@ -335,6 +338,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 +474,7 @@ export {
REGIONS_HETZNER,
REGIONS_WASABI,
REGIONS_S3,
EVENTS,
RELAY_PROVIDERS,
};
@@ -396,5 +507,6 @@ export default {
REGIONS_HETZNER,
REGIONS_WASABI,
REGIONS_S3,
EVENTS,
RELAY_PROVIDERS,
};
+5 -9
View File
@@ -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;
+6 -1
View File
@@ -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';
+2 -2
View File
@@ -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;
}
+51 -6
View File
@@ -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 });
+14 -3
View File
@@ -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
View 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,
};
+2 -2
View File
@@ -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;
}
+4 -1
View File
@@ -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`;
+2 -2
View File
@@ -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];
}
+22 -8
View File
@@ -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;
+10 -2
View File
@@ -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];
}
+38 -5
View File
@@ -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];
},
};
}
+4 -4
View File
@@ -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;
}
+2 -2
View File
@@ -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];
}
+11 -78
View File
@@ -18,8 +18,8 @@
html, body {
font-size: 14px; /* this also defines the overall widget size as all sizes are in rem */
font-family: var(--font-family);
font-weight: 400;
font-family: var(--pankow-font-family);
font-weight: var(--pankow-font-weight-normal);
height: 100%;
width: 100%;
padding: 0;
@@ -36,10 +36,6 @@ html, body {
}
}
b {
font-weight: 700;
}
.shadow {
box-shadow: 0 2px 5px rgba(0,0,0,.1);
}
@@ -48,9 +44,13 @@ b {
height: 100%;
}
strong {
font-weight: var(--pankow-font-weight-bold);
}
h1, h2, h3, h4, h5 {
font-family: var(--font-family--header);
font-weight: 400;
font-weight: var(--pankow-font-weight-bold);
}
h1 {
@@ -190,7 +190,7 @@ form .pankow-checkbox {
}
.text-bold {
font-weight: bold;
font-weight: 500;
}
.text-small {
@@ -205,14 +205,14 @@ form .pankow-checkbox {
.warning-label {
margin-top: 6px;
color: #8a6d3b;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
font-size: 12px;
}
.error-label {
margin-top: 6px;
color: var(--pankow-color-danger);
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
font-size: 12px;
}
@@ -264,7 +264,7 @@ form .pankow-checkbox {
}
.info-label {
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
text-wrap: nowrap;
}
@@ -351,70 +351,3 @@ form .pankow-checkbox {
border-top: solid 1px var(--pankow-input-border-color);
border-bottom: solid 1px var(--pankow-input-border-color);
}
/* eventlog classes shared in system and email eventlog views */
.eventlog-table {
width: 100%;
overflow: auto;
border-spacing: 0px;
table-layout: fixed;
}
.elide-table-cell {
overflow: hidden;
text-overflow: ellipsis;
}
.eventlog-table thead {
background-color: var(--pankow-body-background-color);
top: 0;
position: sticky;
z-index: 1; /* avoids see-through table headers if items in the table have opacity set */
}
.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: 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);
}
+160 -227
View File
@@ -1,6 +1,26 @@
import { prettyBinarySize } from '@cloudron/pankow/utils';
import { RELAY_PROVIDERS, ISTATES, STORAGE_PROVIDERS } from './constants.js';
import { RELAY_PROVIDERS, ISTATES, EVENTS } from './constants.js';
import { Marked } from 'marked';
function safeMarked() {
const marked = new Marked({
renderer: {
link({ href, title, text }) {
if (href && href.startsWith('mailto:')) return text; // mailto is rendered as text
const titleAttr = title ? ` title="${title}"` : '';
const isAbsolute = href && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//'));
const targetAttr = isAbsolute ? ' target="_blank"' : '';
return `<a href="${href}"${targetAttr}${titleAttr}>${text}</a>`;
}
}
});
return marked;
}
function renderSafeMarkdown(text) {
return safeMarked().parse(text);
}
function prettyRelayProviderName(provider) {
if (provider === 'noop') return 'Disabled (no email will be sent)';
@@ -35,139 +55,7 @@ function s3like(provider) {
|| provider === 'contabo-objectstorage' || provider === 'synology-c2-objectstorage';
}
function regionName(provider, endpoint) {
const storageProvider = STORAGE_PROVIDERS.find(sp => sp.value === provider);
const regions = storageProvider.regions;
if (!regions) return endpoint;
const region = regions.find(r => r.value === endpoint);
if (!region) return endpoint;
return region.name;
}
function prettySiteLocation(site) {
switch (site.provider) {
case 'filesystem':
return site.config.backupDir + (site.config.prefix ? `/${site.config.prefix}` : '');
case 'disk':
case 'ext4':
case 'xfs':
case 'mountpoint':
return (site.config.mountOptions.diskPath || site.config.mountPoint) + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 'cifs':
case 'nfs':
case 'sshfs':
return site.config.mountOptions.host + ':' + site.config.mountOptions.remoteDir + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 's3':
return site.config.region + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 'minio':
return site.config.endpoint + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 'gcs':
return site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
default:
return regionName(site.provider, site.config.endpoint) + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
}
}
function eventlogDetails(eventLog, app = null, appIdContext = '') {
const ACTION_ACTIVATE = 'cloudron.activate';
const ACTION_PROVISION = 'cloudron.provision';
const ACTION_RESTORE = 'cloudron.restore';
const ACTION_APP_CLONE = 'app.clone';
const ACTION_APP_REPAIR = 'app.repair';
const ACTION_APP_CONFIGURE = 'app.configure';
const ACTION_APP_INSTALL = 'app.install';
const ACTION_APP_RESTORE = 'app.restore';
const ACTION_APP_IMPORT = 'app.import';
const ACTION_APP_UNINSTALL = 'app.uninstall';
const ACTION_APP_UPDATE = 'app.update';
const ACTION_APP_UPDATE_FINISH = 'app.update.finish';
const ACTION_APP_BACKUP = 'app.backup';
const ACTION_APP_BACKUP_FINISH = 'app.backup.finish';
const ACTION_APP_LOGIN = 'app.login';
const ACTION_APP_OOM = 'app.oom';
const ACTION_APP_UP = 'app.up';
const ACTION_APP_DOWN = 'app.down';
const ACTION_APP_START = 'app.start';
const ACTION_APP_STOP = 'app.stop';
const ACTION_APP_RESTART = 'app.restart';
const ACTION_ARCHIVES_ADD = 'archives.add';
const ACTION_ARCHIVES_DEL = 'archives.del';
const ACTION_BACKUP_FINISH = 'backup.finish';
const ACTION_BACKUP_START = 'backup.start';
const ACTION_BACKUP_CLEANUP_START = 'backup.cleanup.start';
const ACTION_BACKUP_CLEANUP_FINISH = 'backup.cleanup.finish';
const ACTION_BACKUP_SITE_ADD = 'backupsite.add';
const ACTION_BACKUP_SITE_REMOVE = 'backupsite.remove';
const ACTION_BACKUP_SITE_UPDATE = 'backupsite.update';
const ACTION_BRANDING_AVATAR = 'branding.avatar';
const ACTION_BRANDING_NAME = 'branding.name';
const ACTION_BRANDING_FOOTER = 'branding.footer';
const ACTION_CERTIFICATE_NEW = 'certificate.new';
const ACTION_CERTIFICATE_RENEWAL = 'certificate.renew';
const ACTION_CERTIFICATE_CLEANUP = 'certificate.cleanup';
const ACTION_DASHBOARD_DOMAIN_UPDATE = 'dashboard.domain.update';
const ACTION_DIRECTORY_SERVER_CONFIGURE = 'directoryserver.configure';
const ACTION_DOMAIN_ADD = 'domain.add';
const ACTION_DOMAIN_UPDATE = 'domain.update';
const ACTION_DOMAIN_REMOVE = 'domain.remove';
const ACTION_EXTERNAL_LDAP_CONFIGURE = 'externalldap.configure';
const ACTION_GROUP_ADD = 'group.add';
const ACTION_GROUP_UPDATE = 'group.update';
const ACTION_GROUP_REMOVE = 'group.remove';
const ACTION_GROUP_MEMBERSHIP = 'group.membership';
const ACTION_INSTALL_FINISH = 'cloudron.install.finish';
const ACTION_START = 'cloudron.start';
const ACTION_SERVICE_CONFIGURE = 'service.configure';
const ACTION_SERVICE_REBUILD = 'service.rebuild';
const ACTION_SERVICE_RESTART = 'service.restart';
const ACTION_UPDATE = 'cloudron.update';
const ACTION_UPDATE_FINISH = 'cloudron.update.finish';
const ACTION_USER_ADD = 'user.add';
const ACTION_USER_LOGIN = 'user.login';
const ACTION_USER_LOGIN_GHOST = 'user.login.ghost';
const ACTION_USER_LOGOUT = 'user.logout';
const ACTION_USER_REMOVE = 'user.remove';
const ACTION_USER_UPDATE = 'user.update';
const ACTION_USER_TRANSFER = 'user.transfer';
const ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE = 'userdirectory.profileconfig.update';
const ACTION_MAIL_LOCATION = 'mail.location';
const ACTION_MAIL_ENABLED = 'mail.enabled';
const ACTION_MAIL_DISABLED = 'mail.disabled';
const ACTION_MAIL_MAILBOX_ADD = 'mail.box.add';
const ACTION_MAIL_MAILBOX_UPDATE = 'mail.box.update';
const ACTION_MAIL_MAILBOX_REMOVE = 'mail.box.remove';
const ACTION_MAIL_LIST_ADD = 'mail.list.add';
const ACTION_MAIL_LIST_UPDATE = 'mail.list.update';
const ACTION_MAIL_LIST_REMOVE = 'mail.list.remove';
const ACTION_REGISTRY_ADD = 'registry.add';
const ACTION_REGISTRY_UPDATE ='registry.update';
const ACTION_REGISTRY_DEL = 'registry.del';
const ACTION_SUPPORT_TICKET = 'support.ticket';
const ACTION_SUPPORT_SSH = 'support.ssh';
const ACTION_VOLUME_ADD = 'volume.add';
const ACTION_VOLUME_UPDATE = 'volume.update';
const ACTION_VOLUME_REMOVE = 'volume.remove';
const ACTION_DYNDNS_UPDATE = 'dyndns.update';
const data = eventLog.data;
const errorMessage = data.errorMessage;
let details;
@@ -181,16 +69,16 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
}
switch (eventLog.action) {
case ACTION_ACTIVATE:
case EVENTS.ACTIVATE:
return 'Cloudron was activated';
case ACTION_PROVISION:
case EVENTS.PROVISION:
return 'Cloudron was setup';
case ACTION_RESTORE:
case EVENTS.RESTORE:
return 'Cloudron was restored using backup at ' + data.remotePath;
case ACTION_APP_CONFIGURE: {
case EVENTS.APP_CONFIGURE: {
if (!data.app) return '';
app = data.app;
@@ -252,11 +140,15 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
return appName('', app, 'App ') + 'was re-configured';
}
case ACTION_APP_INSTALL:
case EVENTS.APP_INSTALL:
if (!data.app) return '';
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app);
details = data.app.versionsUrl ? 'Community app ' : '';
details += data.app.manifest.title + ' (package v' + data.app.manifest.version + ')';
details += data.sourceBuild ? ' was built and installed' : ' was installed';
details += appIdContext ? '' : ` at ${data.app.fqdn}`;
return details;
case ACTION_APP_RESTORE:
case EVENTS.APP_RESTORE:
if (!data.app) return '';
details = appName('', data.app, 'App') + ' was restored';
// older versions (<3.5) did not have these fields
@@ -265,95 +157,94 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
if (data.remotePath) details += ' using backup at ' + data.remotePath;
return details;
case ACTION_APP_IMPORT:
case EVENTS.APP_IMPORT:
if (!data.app) return '';
details = appName('', data.app, 'App') + ' was imported';
if (data.toManifest) details += ' to version ' + data.toManifest.version;
if (data.remotePath) details += ' using backup at ' + data.remotePath;
return details;
case ACTION_APP_UNINSTALL:
case EVENTS.APP_UNINSTALL:
if (!data.app) return '';
return appName('', data.app, 'App') + ' (package v' + data.app.manifest.version + ') was uninstalled';
case ACTION_APP_UPDATE:
case EVENTS.APP_UPDATE:
if (!data.app) return '';
return 'Update ' + appName('of', data.app) + ' started from v' + data.fromManifest.version + ' to v' + data.toManifest.version;
case ACTION_APP_UPDATE_FINISH:
case EVENTS.APP_UPDATE_FINISH:
if (!data.app) return '';
return appName('', data.app, 'App') + ' was updated to v' + data.app.manifest.version;
case ACTION_APP_BACKUP:
case EVENTS.APP_BACKUP:
if (!data.app) return '';
return 'Backup ' + appName('of', data.app) + ' started';
case ACTION_APP_BACKUP_FINISH:
case EVENTS.APP_BACKUP_FINISH:
if (!data.app) return '';
if (data.errorMessage) return 'Backup ' + appName('of', data.app) + ' failed: ' + data.errorMessage;
if (errorMessage) return 'Backup ' + appName('of', data.app) + ' failed: ' + errorMessage;
else return 'Backup ' + appName('of', data.app) + ' succeeded';
case ACTION_APP_CLONE:
case EVENTS.APP_CLONE:
if (appIdContext === data.oldAppId) return 'App was cloned to ' + data.newApp.fqdn + ' using backup at ' + data.remotePath;
else if (appIdContext === data.appId) return 'App was cloned from ' + data.oldApp.fqdn + ' using backup at ' + data.remotePath;
else return appName('', data.newApp, 'App') + ' was cloned ' + appName('from', data.oldApp) + ' using backup at ' + data.remotePath;
case ACTION_APP_REPAIR:
case EVENTS.APP_REPAIR:
return appName('', data.app, 'App') + ' was re-configured'; // re-configure of email apps is more common?
case ACTION_APP_LOGIN: {
// const app = getApp(data.appId);
case EVENTS.APP_LOGIN: {
if (!app) return '';
return 'App ' + app.fqdn + ' logged in';
return appName('', app, 'App') + ' logged in';
}
case ACTION_APP_OOM:
case EVENTS.APP_OOM:
if (!data.app) return '';
return appName('', data.app, 'App') + ' ran out of memory';
case ACTION_APP_DOWN:
case EVENTS.APP_DOWN:
if (!data.app) return '';
return appName('', data.app, 'App') + ' is down';
case ACTION_APP_UP:
case EVENTS.APP_UP:
if (!data.app) return '';
return appName('', data.app, 'App') + ' is back online';
case ACTION_APP_START:
case EVENTS.APP_START:
if (!data.app) return '';
return appName('', data.app, 'App') + ' was started';
case ACTION_APP_STOP:
case EVENTS.APP_STOP:
if (!data.app) return '';
return appName('', data.app, 'App') + ' was stopped';
case ACTION_APP_RESTART:
case EVENTS.APP_RESTART:
if (!data.app) return '';
return appName('', data.app, 'App') + ' was restarted';
case ACTION_ARCHIVES_ADD:
case EVENTS.ARCHIVES_ADD:
return 'Backup ' + data.backupId + ' added to archive';
case ACTION_ARCHIVES_DEL:
case EVENTS.ARCHIVES_DEL:
return 'Backup ' + data.backupId + ' deleted from archive';
case ACTION_BACKUP_START:
case EVENTS.BACKUP_START:
return `Backup started at site ${data.siteName}`;
case ACTION_BACKUP_FINISH:
case EVENTS.BACKUP_FINISH:
if (!errorMessage) return `Cloudron backup created at site ${data.siteName}`;
else return `Cloudron backup at site ${data.siteName} errored with error: ${errorMessage}`;
case ACTION_BACKUP_CLEANUP_START:
case EVENTS.BACKUP_CLEANUP_START:
return 'Backup cleaner started';
case ACTION_BACKUP_CLEANUP_FINISH:
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backup(s)';
case EVENTS.BACKUP_CLEANUP_FINISH:
return errorMessage ? 'Backup cleaner errored: ' + errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backup(s)';
case ACTION_BACKUP_SITE_ADD:
case EVENTS.BACKUP_SITE_ADD:
return `New backup site ${data.name} added with provider ${data.provider} and format ${data.format}`;
case ACTION_BACKUP_SITE_UPDATE:
case EVENTS.BACKUP_SITE_UPDATE:
if (data.schedule) {
return `Backup site ${data.name} schedule was updated to ${data.schedule}`;
} else if (data.limits) {
@@ -372,170 +263,181 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
return `Backup site ${data.name} was updated`;
}
case ACTION_BACKUP_SITE_REMOVE:
case EVENTS.BACKUP_SITE_REMOVE:
return `Backup site ${data.name} removed`;
case ACTION_BRANDING_AVATAR:
case EVENTS.BACKUP_INTEGRITY_START:
return 'Backup integrity check started'; // for ${data.backupId}
case EVENTS.BACKUP_INTEGRITY_FINISH:
if (!errorMessage) return `Backup integrity check ${data.status}`; // passed or failed
else return `Backup integrity check errored: ${errorMessage}`;
case EVENTS.BRANDING_AVATAR:
return 'Cloudron Avatar Changed';
case ACTION_BRANDING_NAME:
case EVENTS.BRANDING_NAME:
return 'Cloudron Name set to ' + data.name;
case ACTION_BRANDING_FOOTER:
case EVENTS.BRANDING_FOOTER:
return 'Cloudron Footer set to ' + data.footer;
case ACTION_CERTIFICATE_NEW:
return 'Certificate installation for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
case EVENTS.CERTIFICATE_NEW:
details = 'Certificate installation for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
if (data.renewalInfo) details += `. Recommended renewal time is between ${data.renewalInfo.start} and ${data.renewalInfo.end}`;
return details;
case ACTION_CERTIFICATE_RENEWAL:
case EVENTS.CERTIFICATE_RENEWAL:
return 'Certificate renewal for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
case ACTION_CERTIFICATE_CLEANUP:
case EVENTS.CERTIFICATE_CLEANUP:
return 'Certificate(s) of ' + data.domains.join(',') + ' was cleaned up since they expired 6 months ago';
case ACTION_DASHBOARD_DOMAIN_UPDATE:
case EVENTS.DASHBOARD_DOMAIN_UPDATE:
return 'Dashboard domain set to ' + data.fqdn || (data.subdomain + '.' + data.domain);
case ACTION_DIRECTORY_SERVER_CONFIGURE:
case EVENTS.DIRECTORY_SERVER_CONFIGURE:
if (data.fromEnabled !== data.toEnabled) return 'Directory server was ' + (data.toEnabled ? 'enabled' : 'disabled');
else return 'Directory server configuration was changed';
case ACTION_DOMAIN_ADD:
case EVENTS.DOMAIN_ADD:
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was added';
case ACTION_DOMAIN_UPDATE:
case EVENTS.DOMAIN_UPDATE:
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was updated';
case ACTION_DOMAIN_REMOVE:
case EVENTS.DOMAIN_REMOVE:
return 'Domain ' + data.domain + ' was removed';
case ACTION_EXTERNAL_LDAP_CONFIGURE:
case EVENTS.EXTERNAL_LDAP_CONFIGURE:
if (data.config.provider === 'noop') return 'External Directory disabled';
else return 'External Directory set to ' + data.config.url + ' (' + data.config.provider + ')';
case ACTION_GROUP_ADD:
case EVENTS.GROUP_ADD:
return 'Group ' + data.name + ' was added';
case ACTION_GROUP_UPDATE:
case EVENTS.GROUP_UPDATE:
return 'Group name changed from ' + data.oldName + ' to ' + data.group.name;
case ACTION_GROUP_REMOVE:
case EVENTS.GROUP_REMOVE:
return 'Group ' + data.group.name + ' was removed';
case ACTION_GROUP_MEMBERSHIP:
case EVENTS.GROUP_MEMBERSHIP:
return 'Group membership of ' + data.group.name + ' changed. Now was ' + data.userIds.length + ' member(s).';
case ACTION_INSTALL_FINISH:
case EVENTS.INSTALL_FINISH:
return 'Cloudron version ' + data.version + ' installed';
case ACTION_MAIL_LOCATION:
case EVENTS.MAIL_LOCATION:
return 'Mail server location was changed to ' + data.subdomain + (data.subdomain ? '.' : '') + data.domain;
case ACTION_MAIL_ENABLED:
case EVENTS.MAIL_ENABLED:
return 'Mail was enabled for domain ' + data.domain;
case ACTION_MAIL_DISABLED:
case EVENTS.MAIL_DISABLED:
return 'Mail was disabled for domain ' + data.domain;
case ACTION_MAIL_MAILBOX_ADD:
case EVENTS.MAIL_MAILBOX_ADD:
return 'Mailbox ' + data.name + '@' + data.domain + ' was added';
case ACTION_MAIL_MAILBOX_UPDATE:
case EVENTS.MAIL_MAILBOX_UPDATE:
if (data.aliases) return 'Mailbox aliases of ' + data.name + '@' + data.domain + ' was updated';
else return 'Mailbox ' + data.name + '@' + data.domain + ' was updated';
case ACTION_MAIL_MAILBOX_REMOVE:
case EVENTS.MAIL_MAILBOX_REMOVE:
return 'Mailbox ' + data.name + '@' + data.domain + ' was removed';
case ACTION_MAIL_LIST_ADD:
case EVENTS.MAIL_LIST_ADD:
return 'Mail list ' + data.name + '@' + data.domain + 'was added';
case ACTION_MAIL_LIST_UPDATE:
case EVENTS.MAIL_LIST_UPDATE:
return 'Mail list ' + data.name + '@' + data.domain + ' was updated';
case ACTION_MAIL_LIST_REMOVE:
case EVENTS.MAIL_LIST_REMOVE:
return 'Mail list ' + data.name + '@' + data.domain + ' was removed';
case ACTION_REGISTRY_ADD:
case EVENTS.REGISTRY_ADD:
return 'Docker registry ' + data.registry.provider + '@' + data.registry.serverAddress + ' was added';
case ACTION_REGISTRY_UPDATE:
case EVENTS.REGISTRY_UPDATE:
return 'Docker registry updated to ' + data.newRegistry.provider + '@' + data.newRegistry.serverAddress;
case ACTION_REGISTRY_DEL:
case EVENTS.REGISTRY_DEL:
return 'Docker registry ' + data.registry.provider + '@' + data.registry.serverAddress + ' was removed';
case ACTION_START:
case EVENTS.START:
return 'Cloudron started with version ' + data.version;
case ACTION_SERVICE_CONFIGURE:
case EVENTS.SERVICE_CONFIGURE:
return 'Service ' + data.id + ' was configured';
case ACTION_SERVICE_REBUILD:
case EVENTS.SERVICE_REBUILD:
return 'Service ' + data.id + ' was rebuilt';
case ACTION_SERVICE_RESTART:
case EVENTS.SERVICE_RESTART:
return 'Service ' + data.id + ' was restarted';
case ACTION_UPDATE:
case EVENTS.UPDATE:
return 'Cloudron update to version ' + data.boxUpdateInfo.version + ' was started';
case ACTION_UPDATE_FINISH:
if (data.errorMessage) return 'Cloudron update errored. Error: ' + data.errorMessage;
case EVENTS.UPDATE_FINISH:
if (errorMessage) return 'Cloudron update errored. Error: ' + errorMessage;
else return 'Cloudron updated to version ' + data.newVersion;
case ACTION_USER_ADD:
case EVENTS.USER_ADD:
return 'User ' + data.email + (data.user.username ? ' (' + data.user.username + ')' : '') + ' was added';
case ACTION_USER_UPDATE:
case EVENTS.USER_UPDATE:
return 'User ' + (data.user ? (data.user.email + (data.user.username ? ' (' + data.user.username + ')' : '')) : data.userId) + ' was updated';
case ACTION_USER_REMOVE:
case EVENTS.USER_REMOVE:
return 'User ' + (data.user ? (data.user.email + (data.user.username ? ' (' + data.user.username + ')' : '')) : data.userId) + ' was removed';
case ACTION_USER_TRANSFER:
case EVENTS.USER_TRANSFER:
return 'Apps of ' + data.oldOwnerId + ' was transferred to ' + data.newOwnerId;
case ACTION_USER_LOGIN:
case EVENTS.USER_LOGIN:
if (data.mailboxId) {
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to mailbox ' + data.mailboxId;
} else if (data.appId) {
// const app = getApp(data.appId);
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in' + (appIdContext ? '' : ' to ' + (app ? app.fqdn : data.appId));
} else { // can happen with directoryserver
return 'User ' + (data.user ? data.user.username : data.userId) + ' authenticated';
}
case ACTION_USER_LOGIN_GHOST:
case EVENTS.USER_LOGIN_GHOST:
return 'User ' + (data.user ? data.user.username : data.userId) + ' was impersonated';
case ACTION_USER_LOGOUT:
case EVENTS.USER_LOGOUT:
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged out';
case ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE:
case EVENTS.USER_DIRECTORY_PROFILE_CONFIG_UPDATE:
return 'User directory profile config updated. Mandatory 2FA: ' + (data.config.mandatory2FA) + ' Lock profiles: ' + (data.config.lockUserProfiles);
case ACTION_DYNDNS_UPDATE: {
details = data.errorMessage ? 'Error updating DNS. ' : 'Updated DNS. ';
case EVENTS.DYNDNS_UPDATE: {
details = errorMessage ? 'Error updating DNS. ' : 'Updated DNS. ';
if (data.fromIpv4 !== data.toIpv4) details += 'From IPv4 ' + data.fromIpv4 + ' to ' + data.toIpv4 + '. ';
if (data.fromIpv6 !== data.toIpv6) details += 'From IPv6 ' + data.fromIpv6 + ' to ' + data.toIpv6 + '.';
if (data.errorMessage) details += ' ' + data.errorMessage;
if (errorMessage) details += ' ' + errorMessage;
return details;
}
case ACTION_SUPPORT_SSH:
case EVENTS.SUPPORT_SSH:
return 'Remote Support was ' + (data.enable ? 'enabled' : 'disabled');
case ACTION_SUPPORT_TICKET:
case EVENTS.SUPPORT_TICKET:
return 'Support ticket was created';
case ACTION_VOLUME_ADD:
case EVENTS.VOLUME_ADD:
return 'Volume "' + (data.volume || data).name + '" was added';
case ACTION_VOLUME_UPDATE:
case EVENTS.VOLUME_UPDATE:
return 'Volme "' + (data.volume || data).name + '" was updated';
case ACTION_VOLUME_REMOVE:
case EVENTS.VOLUME_REMOUNT:
return 'Volume "' + (data.volume || data).name + '" was remounted';
case EVENTS.VOLUME_REMOVE:
return 'Volume "' + (data.volume || data).name + '" was removed';
default:
@@ -758,8 +660,38 @@ function parseFullBackupPath(fullPath) {
return { prefix, remotePath };
}
function base64urlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64urlEncode(array);
}
async function computeCodeChallenge(verifier) {
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64urlEncode(new Uint8Array(hash));
}
async function startAuthFlow(clientId, apiOrigin) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await computeCodeChallenge(codeVerifier);
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
sessionStorage.setItem('pkce_client_id', clientId);
sessionStorage.setItem('pkce_api_origin', apiOrigin || '');
const redirectUri = window.location.origin + '/authcallback.html';
const base = apiOrigin || '';
return `${base}/openid/auth?client_id=${clientId}&scope=openid email profile&response_type=code&redirect_uri=${redirectUri}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
}
// named exports
export {
renderSafeMarkdown,
prettyRelayProviderName,
download,
mountlike,
@@ -776,12 +708,13 @@ export {
getColor,
prettySchedule,
parseSchedule,
prettySiteLocation,
parseFullBackupPath
parseFullBackupPath,
startAuthFlow
};
// default export
export default {
renderSafeMarkdown,
prettyRelayProviderName,
download,
mountlike,
@@ -798,6 +731,6 @@ export default {
getColor,
prettySchedule,
parseSchedule,
prettySiteLocation,
parseFullBackupPath
parseFullBackupPath,
startAuthFlow
};
+2 -4
View File
@@ -3,7 +3,7 @@
import { ref, useTemplateRef, onMounted } from 'vue';
import { Button, Checkbox, FormGroup, TextInput, PasswordInput, EmailInput } from '@cloudron/pankow';
import ProvisionModel from '../models/ProvisionModel.js';
import { redirectIfNeeded } from '../utils.js';
import { redirectIfNeeded, startAuthFlow } from '../utils.js';
const provisionModel = ProvisionModel.create();
@@ -13,7 +13,6 @@ const displayName = ref('');
const email = ref('');
const username = ref('');
const password = ref('');
const setupToken = ref('');
const acceptLicense = ref(false);
const form = useTemplateRef('form');
@@ -33,7 +32,6 @@ async function onOwnerSubmit() {
password: password.value,
email: email.value,
displayName: displayName.value,
setupToken: setupToken.value,
};
const [error, result] = await provisionModel.createAdmin(data);
@@ -61,7 +59,7 @@ async function onOwnerSubmit() {
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = result;
window.location.href = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
window.location.href = await startAuthFlow('cid-webadmin', '');
}
onMounted(async () => {
+5 -5
View File
@@ -125,20 +125,20 @@ onMounted(async () => {
<br/>
<TableView :columns="columns" :model="archives" :busy="busy" :placeholder="$t('archives.listing.placeholder')">
<template #icon="archive">
<template #icon="{ item:archive }">
<img :src="archive.iconUrl || 'img/appicon_fallback.png'" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'" height="24" width="24"/>
</template>
<!-- for pre-8.2 backups, appConfig can be null -->
<template #location="archive">{{ archive.appConfig ? archive.appConfig.fqdn : '-' }}</template>
<template #location="{ item:archive }">{{ archive.appConfig ? archive.appConfig.fqdn : '-' }}</template>
<template #info="archive">
<template #info="{ item:archive }">
<span v-tooltip="`${archive.manifest.id}@${archive.manifest.version}`">{{ archive.manifest.title }}</span>
</template>
<template #creationTime="archive">{{ prettyLongDate(archive.creationTime) }}</template>
<template #creationTime="{ item:archive }">{{ prettyLongDate(archive.creationTime) }}</template>
<template #actions="archive">
<template #actions="{ item:archive }">
<ActionBar :actions="createActionMenu(archive)"/>
</template>
</TableView>
+62 -54
View File
@@ -5,7 +5,7 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import { Button, ButtonGroup, ProgressBar } from '@cloudron/pankow';
import { Button, ButtonGroup, ProgressBar, InputDialog } from '@cloudron/pankow';
import PostInstallDialog from '../components/PostInstallDialog.vue';
import SftpInfoDialog from '../components/SftpInfoDialog.vue';
import Access from '../components/app/Access.vue';
@@ -26,13 +26,13 @@ import Storage from '../components/app/Storage.vue';
import Uninstall from '../components/app/Uninstall.vue';
import Updates from '../components/app/Updates.vue';
import AppsModel from '../models/AppsModel.js';
import TasksModel from '../models/TasksModel.js';
import { API_ORIGIN, APP_TYPES, ISTATES, RSTATES, HSTATES } from '../constants.js';
const appsModel = AppsModel.create();
const tasksModel = TasksModel.create();
const installationStateLabel = AppsModel.installationStateLabel;
const inputDialog = useTemplateRef('inputDialog');
const busy = ref(true);
const id = ref('');
const app = ref(null);
@@ -103,6 +103,16 @@ async function refresh() {
target: '_blank',
});
if (result.versionsUrl) {
infoMenu.value.push({ separator: true });
infoMenu.value.push({
label: 'Versions URL',
href: result.versionsUrl,
target: '_blank',
});
}
infoMenu.value.push({ separator: true });
infoMenu.value.push({
label: t('app.projectWebsiteAction'),
@@ -139,7 +149,7 @@ function isViewEnabled(view, errorState) {
} else if (view === 'resources') {
return errorState === ISTATES.PENDING_RESIZE || errorState === ISTATES.PENDING_RECREATE_CONTAINER;
} else if (view === 'storage') {
return errorState === ISTATES.PENDING_DATA_DIR_MIGRATION || errorState === ISTATES.PENDING_RECREATE_CONTAINER;
return true; // allow in all states because a volume error can happen at any time
} else if (view === 'services') {
return errorState === ISTATES.PENDING_SERVICES_CHANGE;
} else if (view === 'email') {
@@ -151,53 +161,12 @@ function isViewEnabled(view, errorState) {
return false;
}
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 (app.value.error) {
if (app.value.error.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
else return TARGET_RUN_STATE.STOP;
} else {
if (app.value.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(app.value.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(app.value.id);
if (error) {
toggleRunStateBusy.value = false;
return console.error(error);
}
setTimeout(() => toggleRunStateBusy.value = false, 3000);
}
async function onStopAppTask() {
if (!app.value.taskId) return;
busyStopTask.value = true;
const [error] = await tasksModel.stop(app.value.taskId);
const [error] = await appsModel.stopAppTask(app.value.id, app.value.taskId);
if (error) console.error(error);
busyStopTask.value = false;
@@ -226,6 +195,48 @@ function hashChange() {
window.location.hash = `/app/${id.value}/${newView}`;
}
const busyRestart = ref(false);
async function onRestartApp() {
if (app.value.runState === RSTATES.STOPPED) {
busyRestart.value = true;
const [error] = await appsModel.restart(id.value);
if (error) return console.error(error);
busyRestart.value = false;
return;
}
const confirmed = await inputDialog.value.confirm({
message: t('filemanager.toolbar.restartApp') + '?',
confirmLabel: t('main.action.restart'),
confirmStyle: 'danger',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!confirmed) return;
busyRestart.value = true;
const [error] = await appsModel.restart(id.value);
if (error) return console.error(error);
busyRestart.value = false;
}
const busyStart = ref(false);
async function onStartApp() {
busyStart.value = true;
const [error] = await appsModel.start(id.value);
if (error) return console.error(error);
setTimeout(() => busyStart.value = false, 3000);
}
onMounted(async () => {
const tmp = window.location.hash.slice('#/app/'.length);
if (!tmp) return;
@@ -284,6 +295,7 @@ onBeforeUnmount(() => {
<template>
<div class="configure-outer">
<InputDialog ref="inputDialog" />
<PostInstallDialog ref="postInstallDialog"/>
<SftpInfoDialog ref="sftpInfoDialog"/>
@@ -303,12 +315,8 @@ onBeforeUnmount(() => {
<Button v-if="app.taskId" danger tool plain icon="fa-solid fa-xmark" v-tooltip="'Cancel Task'" :loading="busyStopTask" :disabled="busyStopTask" @click="onStopAppTask()"/>
<Button :menu="views" secondary class="pankow-no-desktop" tool>{{ views.find(v => v.id === currentView).label }}</Button>
<!--
TODO check if this should be shown on stop confirmation
<div>{{ $t('app.uninstall.startStop.description') }}</div>
-->
<Button v-if="!app.progress && targetRunState() === TARGET_RUN_STATE.START" secondary tool icon="fa-solid fa-circle-play" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.startAction')" @click="onStartApp()"/>
<Button v-else-if="!app.progress" secondary tool icon="fa-solid fa-circle-stop" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.stopAction')" @click="onStopApp()"/>
<Button v-if="!app.progress && app.runState !== RSTATES.STOPPED" secondary tool icon="fa-solid fa-arrows-rotate" :loading="busyRestart" :disabled="busyRestart" v-tooltip="$t('filemanager.toolbar.restartApp')" @click="onRestartApp()"/>
<Button v-if="!app.progress && app.runState === RSTATES.STOPPED" secondary tool icon="fa-solid fa-circle-play" :loading="busyStart" :disabled="busyStart" v-tooltip="$t('app.start.action')" @click="onStartApp()"/>
<ButtonGroup>
<Button secondary tool :href="`/logs.html?appId=${app.id}`" target="_blank" v-tooltip="$t('app.logsActionTooltip')" icon="fa-solid fa-align-left" />
@@ -342,7 +350,7 @@ onBeforeUnmount(() => {
<Security v-else-if="currentView === 'security'" :app="app"/>
<Email v-else-if="currentView === 'email'" :app="app"/>
<Cron v-else-if="currentView === 'cron'" :app="app"/>
<Updates v-else-if="currentView === 'updates'" :app="app"/>
<Updates v-else-if="currentView === 'updates'" :app="app" :refresh-app="refresh"/>
<Backups v-else-if="currentView === 'backups'" :app="app"/>
<Repair v-else-if="currentView === 'repair'" :app="app"/>
<Eventlog v-else-if="currentView === 'eventlog'" :app="app"/>
@@ -485,7 +493,7 @@ onBeforeUnmount(() => {
.configure-menu-item[active] > a,
.configure-menu-item[active] > span {
color: var(--pankow-color-primary-active);
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
}
.configure-menu-item:hover > a,
+64 -17
View File
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, computed, useTemplateRef, onActivated, onDeactivated, inject } from 'vue';
import { ref, computed, useTemplateRef, watch, onActivated, onDeactivated, inject } from 'vue';
import { Button, SingleSelect, Icon, TableView, TextInput, ProgressBar } from '@cloudron/pankow';
import { API_ORIGIN, APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
import ActionBar from '../components/ActionBar.vue';
@@ -45,6 +45,27 @@ const stateFilterOptions = [
{ id: 'update_available', label: 'Update available' },
{ id: 'not_responding', label: 'Not responding' },
];
const STATE_FILTER_IDS = new Set(['', 'running', 'stopped', 'update_available', 'not_responding']);
const APPS_HASH_PATH = '#/apps';
function syncFiltersToUrl() {
if (window.location.hash.split('?')[0] !== APPS_HASH_PATH) return;
const params = new URLSearchParams();
if (filter.value) params.set('search', filter.value);
if (domainFilter.value) params.set('domain', domainFilter.value);
if (stateFilter.value) params.set('state', stateFilter.value);
if (tagFilter.value) params.set('tag', tagFilter.value);
const query = params.toString();
const newHash = query ? `${APPS_HASH_PATH}?${query}` : APPS_HASH_PATH;
history.replaceState(null, '', newHash);
}
let filterDebounceTimer;
function scheduleSyncFilterToUrl() {
clearTimeout(filterDebounceTimer);
filterDebounceTimer = setTimeout(syncFiltersToUrl, 350);
}
const listColumns = {
icon: {
width: '40px'
@@ -283,9 +304,39 @@ function onKeyDownHandler(event) {
if (event.key === 'Escape') filter.value = '';
}
function onAppsHashChange() {
if (window.location.hash.split('?')[0] !== APPS_HASH_PATH) return;
if (window.location.hash.indexOf('?') >= 0) return;
filter.value = '';
domainFilter.value = '';
stateFilter.value = '';
tagFilter.value = '';
}
watch(filter, () => scheduleSyncFilterToUrl());
watch(domainFilter, syncFiltersToUrl);
watch(stateFilter, syncFiltersToUrl);
watch(tagFilter, syncFiltersToUrl);
onActivated(async () => {
setItemWidth();
const qi = window.location.hash.indexOf('?');
const params = qi >= 0 ? new URLSearchParams(window.location.hash.slice(qi + 1)) : new URLSearchParams();
filter.value = params.get('search') ?? '';
const stateParam = params.get('state') ?? '';
stateFilter.value = STATE_FILTER_IDS.has(stateParam) ? stateParam : '';
const domainParam = params.get('domain') ?? '';
const domainExists = domainFilterOptions.value.some(opt => opt.id === domainParam);
domainFilter.value = domainExists ? domainParam : '';
const tagParam = params.get('tag') ?? '';
const tagExists = tagFilterOptions.value.some(opt => opt.id === tagParam);
tagFilter.value = tagExists ? tagParam : '';
await refreshApps();
ready.value = true;
@@ -293,23 +344,19 @@ onActivated(async () => {
if (error) return console.error(error);
domainFilterOptions.value = [{ id: '', domain: 'All domains', }].concat(result.map(d => { d.id = d.domain; return d; }));
domainFilter.value = domainFilterOptions.value[0].id;
stateFilter.value = stateFilterOptions[0].id;
tagFilter.value = tagFilterOptions.value[0].id;
refreshInterval = setInterval(refreshApps, 5000);
window.addEventListener('resize', setItemWidth);
window.addEventListener('keydown', onKeyDownHandler);
window.addEventListener('hashchange', onAppsHashChange);
if (window.innerWidth > 575) setTimeout(() => searchInput.value.focus(), 0);
});
onDeactivated(() => {
filter.value = '';
window.removeEventListener('keydown', onKeyDownHandler);
window.removeEventListener('hashchange', onAppsHashChange);
clearInterval(refreshInterval);
});
@@ -359,35 +406,35 @@ onDeactivated(() => {
<div class="list" v-if="viewType === VIEW_TYPE.LIST && apps.length !== 0">
<TableView :columns="listColumns" :model="filteredApps">
<template #icon="app">
<template #icon="{ item:app }">
<a :href="app.origin" target="_blank">
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
</a>
</template>
<template #label="app">
<template #label="{ item:app }">
<a :href="app.origin" target="_blank">
{{ app.label || app.subdomain || app.fqdn }}
</a>
</template>
<template #appTitle="app">
<template #appTitle="{ item:app }">
{{ app.manifest.title }}
</template>
<template #fqdn="app">
<template #fqdn="{ item:app }">
<a :href="app.origin" target="_blank">
{{ app.fqdn }}
</a>
</template>
<template #status="app">
<template #status="{ item:app }">
<div class="list-status">
{{ AppsModel.installationStateLabel(app) }}
<ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
</div>
</template>
<template #checklist="app">
<template #checklist="{ item:app }">
<a class="list-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" :href="`#/app/${app.id}/info`"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
<a class="list-item-update-indicator" v-if="app.updateInfo" @click.stop :href="isOperator(app) ? `#/app/${app.id}/updates` : null" v-tooltip="$t('app.updateAvailableTooltip')"><i class="fa-solid fa-arrow-up"/></a>
</template>
<template #sso="app">
<template #sso="{ item:app }">
<div v-show="app.type !== APP_TYPES.LINK">
<Icon icon="fa-brands fa-openid" v-show="app.ssoAuth && app.manifest.addons.oidc" v-tooltip="$t('apps.auth.openid')" />
<Icon icon="fas fa-user" v-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" v-tooltip="$t('apps.auth.sso')" />
@@ -395,7 +442,7 @@ onDeactivated(() => {
<Icon icon="fas fa-envelope" v-show="app.manifest.addons.email" v-tooltip="$t('apps.auth.email')" />
</div>
</template>
<template #actions="app">
<template #actions="{ item:app }">
<ActionBar v-if="app.type === APP_TYPES.LINK" :actions="createAppLinkActionMenu(app)" />
<ActionBar v-else :actions="createAppActionMenu(app)" />
</template>
@@ -405,8 +452,8 @@ onDeactivated(() => {
<div v-if="apps.length === 0" class="empty-placeholder">
<!-- admins or not-->
<div v-if="profile.isAtLeastAdmin">
<h4>{{ $t('apps.noApps.title') }}</h4>
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })"></h5>
<h4 style="font-weight: 400">{{ $t('apps.noApps.title') }}</h4>
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })" style="font-weight: 400"></h5>
</div>
<div v-else>
<h4>{{ $t('apps.noAccess.title') }}</h4>
+127 -13
View File
@@ -13,6 +13,7 @@ import DomainsModel from '../models/DomainsModel.js';
import ApplinkDialog from '../components/ApplinkDialog.vue';
import AppInstallDialog from '../components/AppInstallDialog.vue';
import AppStoreItem from '../components/AppStoreItem.vue';
import CommunityAppDialog from '../components/CommunityAppDialog.vue';
const appsModel = AppsModel.create();
const appstoreModel = AppstoreModel.create();
@@ -22,6 +23,17 @@ const ready = ref(false);
const apps = ref([]);
const search = ref('');
const domains = ref([]);
const addCustomAppMenu = ref([{
label: 'App proxy',
action: () => { window.location.href="/#/appstore/io.cloudron.builtin.appproxy"; }
}, {
label: 'Community app',
action: () => { onInstallCommunityApp(); }
}, {
label: 'External link',
action: () => { onAddAppLink(); }
},
]);
// clear category on search
watch(search, (newValue) => {
@@ -47,8 +59,9 @@ const filteredApps = computed(() => {
return filterForNewApps(apps.value);
} else {
return apps.value.filter(a => {
if (a.manifest.tags.join().toLowerCase().indexOf(category.value) !== -1) return true;
return false;
const matchTags = categoryTagMap[category.value];
if (!matchTags) return a.manifest.tags.some(tag => tag.toLowerCase() === category.value);
return a.manifest.tags.some(tag => matchTags.includes(tag.toLowerCase()));
});
}
}
@@ -81,32 +94,118 @@ const category = ref('');
const categories = [
{ id: '', label: t('appstore.category.all') },
{ id: 'new', label: t('appstore.category.newApps') },
{ id: 'ai', label: 'AI'},
{ id: 'analytics', label: 'Analytics'},
{ id: 'automation', label: 'Automation'},
{ id: 'blog', label: 'Blog'},
{ id: 'calendar', label: 'Calendar'},
{ id: 'chat', label: 'Chat'},
{ id: 'crm', label: 'CRM'},
{ id: 'document', label: 'Documents'},
{ id: 'email', label: 'Email'},
{ id: 'federated', label: 'Federated'},
{ id: 'finance', label: 'Finance'},
{ id: 'forum', label: 'Forum'},
{ id: 'fun', label: 'Fun'},
{ id: 'gallery', label: 'Gallery'},
{ id: 'game', label: 'Games'},
{ id: 'git', label: 'Code Hosting'},
{ id: 'hosting', label: 'Web Hosting'},
{ id: 'learning', label: 'Learning'},
{ id: 'media', label: 'Media'},
{ id: 'no-code', label: 'No-code'},
{ id: 'notes', label: 'Notes'},
{ id: 'project', label: 'Project Management'},
{ id: 'rss', label: 'RSS'},
{ id: 'security', label: 'Security'},
{ id: 'sync', label: 'File Sync'},
{ id: 'voip', label: 'VoIP'},
{ id: 'vpn', label: 'VPN'},
{ id: 'wiki', label: 'Wiki'},
];
const categoryTagMap = {
'ai': ['ai', 'ollama', 'chatgpt', 'llm', 'machine-learning', 'ai-assistant'],
'analytics': ['analytics', 'tracker', 'monitoring', 'graphs', 'metrics', 'bi', 'tableau',
'graphite', 'profiling', 'tag manager', 'visualization', 'data', 'statistics', 'status',
'tracking'],
'automation': ['automation', 'scheduling', 'cron', 'zapier', 'homeautomation',
'home', 'assistant'],
'blog': ['blog', 'weblog', 'ghost', 'wordpress', 'comments'],
'calendar': ['calendar', 'calendars', 'caldav', 'carddav', 'appointment', 'appointments',
'events', 'schedule', 'scheduler', 'doodle', 'calendly', 'ics', 'groupware', 'contacts',
'addressbook', 'meetings'],
'chat': ['chat', 'webchat', 'slack', 'gitter', 'teams', 'messaging',
'instant-messaging', 'livechat', 'chat-widget', 'irc', 'riot', 'matrix',
'zulip', 'conversation', 'communication', 'social'],
'crm': ['crm', 'salesforce', 'sugarcrm', 'suitecrm', 'prm', 'erp',
'pipedrive', 'customer support', 'helpdesk', 'zendesk', 'helpscout',
'support', 'tickets', 'ticketing software', 'help', 'service management',
'dolibarr'],
'document': ['document', 'documents', 'docs', 'office', 'office365',
'googledocs', 'editor', 'ocr', 'signature', 'docusign', 'pandadoc',
'signning', 'collaboration', 'collaborative', 'digital', 'pdf',
'draw', 'sketch', 'whiteboard', 'design', 'figma', 'prototyping',
'writing', 'excel'],
'email': ['email', 'mail', 'newsletter', 'campaign', 'mailchimp',
'sendgrid', 'sendinblue', 'webmail', 'imap', 'smtp', 'gmail',
'fastmail', 'marketing', 'campaigns', 'listmonk'],
'finance': ['finance', 'finances', 'accounting', 'money', 'invoices',
'mint', 'gnucash', 'invoice', 'expense', 'quote', 'expences',
'firefly', 'firefly-iii', 'actual', 'control', 'track',
'shop', 'inventory'],
'forum': ['forum', 'community', 'discourse', 'bb', 'phpbb', 'vanilla',
'stackoverflow', 'q&a platform', 'feedback', 'feature requests',
'canny', 'portal', 'userresponse', 'uservoice'],
'gallery': ['gallery', 'photo', 'pictures', 'images', 'picasa',
'photos', 'instagram', 'flickr', 'imagebin', 'screencloud'],
'game': ['game', 'games', 'gaming', 'multiplayer'],
'git': ['version control', 'git', 'code hosting', 'code', 'development',
'github', 'bitbucket', 'gitlab', 'gitea', 'ci', 'cd', 'docker',
'registry', 'harbor', 'devtools', 'build', 'npm', 'repository',
'artifactory', 'devops', 'drone', 'actions', 'bash', 'powershell',
'golang', 'rails', 'vuejs', 'paste', 'pastebin'],
'hosting': ['hosting', 'fileserver', 'webserver', 'server', 'cms',
'static', 'website', 'jekyll', 'pages', 'netlify', 'lamp', 'stacks',
'apache', 'php', 'squarespace', 'wix', 'mysql', 'proxy', 'external',
'heritage'],
'media': ['media', 'streaming', 'video', 'audio', 'movies',
'mediacenter', 'plex', 'netflix', 'music', 'subsonic', 'spotify',
'last.fm', 'mp3', 'music player', 'podcast', 'audiobook', 'videos',
'youtube', 'vimeo', 'rtmp', 'obs', 'livestream', 'broadcast',
'stream', 'media server', 'books', 'ebook', 'ebooks', 'epub', 'mobi',
'calibre', 'kindle', 'kobo', 'opds', 'comics', 'manga', 'goodreads',
'gpodder', 'torrent', 'bittorrent', 'qbittorrent', 'vuetorrent'],
'no-code': ['no-code', 'nocode', 'airtable', 'spreadsheet', 'database',
'graphql', 'typeform', 'api', 'contentful', 'strapi', 'sql',
'applications', 'automations', 'dashboards', 'collaborate',
'survey', 'surveymonkey', 'qualtrics', 'polls', 'forms', 'chatbot',
'converter', 'tools'],
'notes': ['notes', 'personal', 'evernote', 'memo', 'keep', 'onenote',
'bookmarks', 'todo', 'diary', 'markdown', 'bookmark',
'bookmark-manager', 'ideas', 'feed', 'productivity',
'archive', 'readlater', 'readability', 'pocket', 'instapaper',
'delicous', 'recipe', 'cookbook', 'cooking', 'food', 'meal-planner',
'household'],
'project': ['project', 'management', 'kanban', 'task management',
'trello', 'asana', 'jira', 'basecamp', 'agile', 'scrum', 'gantt',
'timeline', 'backlog', 'planning', 'organize', 'tasks', 'notion',
'project management', 'bug', 'issue', 'srints', 'wekan',
'time', 'clockify', 'harvest', 'toggl', 'asset', 'device', 'it',
'mdm', 'license tracking', 'assets management', 'software audit'],
'rss': ['rss', 'atom', 'reader', 'greader', 'feedly', 'news',
'news feeds', 'feeds', 'bridge'],
'security': ['password', 'bitwarden', 'vaultwarden', 'lastpass',
'1password', 'manager', 'encryption', 'key', 'secret', 'vault',
'hashicorp', 'auth', 'sso', 'oidc', 'openid', 'saml', '2fa',
'two-factor authentication', 'dns', 'adblock', 'pihole', 'ublock',
'privacy', 'vpn', 'openvpn', 'network', 'wireguard',
'keycloak'],
'sync': ['sync', 'file sharing', 'files', 'dropbox', 'cloud', 'file',
'sharing', 'storage', 's3', 'objectstore', 'sftp', 'ftp', 'webdav',
'file browser', 'airdrop', 'filesharing', 'filemanager', 'share'],
'voip': ['voip', 'voice', 'conference', 'zoom', 'call', 'webrtc',
'meeting', 'web meeting', 'p2p', 'sfu', 'bbb', 'im', 'videochat'],
'wiki': ['wiki', 'confluence', 'sharepoint', 'knowledgebase',
'knowledge base'],
};
async function onAppInstallDialogClose() {
window.location.href = '#/appstore';
}
@@ -143,16 +242,21 @@ async function onHashChange() {
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
const version = params.get('version') || 'latest';
try {
await appInstallDialog.value.open(appId, version, installedApps.value.length >= features.value.appMaxCount, domains.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
inputDialog.value.info({
const [error, result] = await appstoreModel.get(appId, version);
if (error || !result.manifest) {
if (error) console.error(error);
return inputDialog.value.info({
title: t('appstore.appNotFoundDialog.title'),
message: t('appstore.appNotFoundDialog.description', { appId, version }),
confirmLabel: t('main.dialog.close'),
});
}
const packageData = {
...result, // { id, creationDate, publishState, manifest, iconUrl }
appStoreId: `${appId}@${version}`
};
appInstallDialog.value.open(packageData, installedApps.value.length >= features.value.appMaxCount, domains.value);
}
}
@@ -178,6 +282,7 @@ async function getDomains() {
domains.value = result;
}
const applinkDialog = useTemplateRef('applinkDialog');
const communityAppDialog = useTemplateRef('communityAppDialog');
function onAddAppLink() {
applinkDialog.value.open();
@@ -187,6 +292,14 @@ function onApplinkAdded() {
window.location.href = '#/apps';
}
function onInstallCommunityApp() {
communityAppDialog.value.open();
}
function onCommunityAppSuccess(packageData) {
appInstallDialog.value.open(packageData, installedApps.value.length >= features.value.appMaxCount, domains.value);
}
onActivated(async () => {
setItemWidth();
@@ -222,13 +335,13 @@ onDeactivated(() => {
<div ref="view" class="content-large" style="width: 100%; height: 100%;">
<InputDialog ref="inputDialog"/>
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
<CommunityAppDialog ref="communityAppDialog" @success="onCommunityAppSuccess"/>
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
<div class="filter-bar">
<SingleSelect v-model="category" :options="categories" option-key="id" option-label="label" :disabled="!ready"/>
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
<Button tool outline href="/#/appstore/io.cloudron.builtin.appproxy">Add app proxy</Button>
<Button tool outline @click="onAddAppLink()">Add external link</Button>
<Button tool :menu="addCustomAppMenu">{{ $t('appstore.action.addCustomApp') }} </Button>
</div>
<div v-if="!ready" style="margin-top: 15px">
@@ -279,6 +392,7 @@ onDeactivated(() => {
.filter-bar {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: space-between;
}
+19 -18
View File
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, reactive, inject } from 'vue';
import { ref, onMounted, useTemplateRef, reactive, inject, computed } from 'vue';
import { Button, ProgressBar, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import ActionBar from '../components/ActionBar.vue';
@@ -20,7 +20,7 @@ import BackupSitesModel from '../models/BackupSitesModel.js';
import TasksModel from '../models/TasksModel.js';
import AppsModel from '../models/AppsModel.js';
import { prettySchedule, prettySiteLocation } from '../utils.js';
import { prettySchedule } from '../utils.js';
const profile = inject('profile');
@@ -34,6 +34,7 @@ const systemBackupList = useTemplateRef('systemBackupList');
const sites = ref([]);
const busy = ref(true);
const hasUpdateBackupSite = computed(() => sites.value.some(site => site.enableForUpdates));
const backupSiteAddDialog = useTemplateRef('backupSiteAddDialog');
function onAdd() {
@@ -296,10 +297,10 @@ onMounted(async () => {
<br/>
<div>
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
<div class="backup-site" v-for="site in sites" :key="site.id">
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
<div class="warning-label" style="margin-bottom: 10px;" v-if="!busy && sites.length > 0 && !hasUpdateBackupSite">{{ $t('backup.sites.noAutomaticUpdateBackupWarning') }}</div>
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
<div class="backup-site" v-for="site in sites" :key="site.id">
<div style="display: flex; align-items: start; margin-top: 6px;">
<StateLED :busy="site.status.busy" :state="site.status.state"/>
</div>
@@ -317,12 +318,15 @@ onMounted(async () => {
<div>
<b>Storage:</b> {{ site.provider }} ({{ site.format }})
<span>at {{ prettySiteLocation(site) }}</span>
<span>at {{ site.locationLabel }}</span>
</div>
<div>
<b>Content:</b> <span v-html="prettyBackupContents(site.contents)"></span>
</div>
<div>
<b>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}:</b> {{ site.enableForUpdates ? $t('main.dialog.yes') : $t('main.dialog.no') }}
</div>
<div>
<b>{{ $t('backups.schedule.schedule') }}:</b> {{ prettySchedule(site.schedule) }}
@@ -333,8 +337,8 @@ onMounted(async () => {
<div class="backup-site-task">
<div v-if="!site.task">
<b>{{ $t('backup.sites.lastRun') }}:</b>
<span v-if="site.taskLoaded">Never</span>
<span v-else>...</span>
<span v-if="site.taskLoaded"> Never</span>
<span v-else> ...</span>
</div>
<div v-if="site.task && site.task.success"><b>{{ $t('backup.sites.lastRun') }}:</b> {{ prettyLongDate(site.task.ts) }}</div>
<div v-if="site.task && site.task.error">
@@ -344,19 +348,16 @@ onMounted(async () => {
</div>
</div>
<div style="margin-top: 10px;" class="text-danger" v-if="site.status.message" v-html="site.status.message"></div>
<div v-if="site.task && site.task.running">
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
<div style="flex-grow: 1; overflow: hidden;">
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
<div style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
</div>
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
<div v-if="site.task && site.task.running" style="margin-top: 10px; display: grid; grid-template-columns: 1fr auto auto; column-gap: 10px; align-items: center;">
<div style="overflow: hidden;">
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
</div>
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
<div style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
</div>
</div>
</div>
</div>
</div>
</Section>
+6 -4
View File
@@ -5,7 +5,7 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, computed, inject } from 'vue';
import { Button, TableView, TextInput, InputDialog } from '@cloudron/pankow';
import { Button, TableView, TextInput, InputDialog, ProgressBar } from '@cloudron/pankow';
import Certificates from '../components/Certificates.vue';
import ActionBar from '../components/ActionBar.vue';
import SyncDns from '../components/SyncDns.vue';
@@ -113,7 +113,7 @@ async function refreshDomains() {
domains.value = result;
dashboardDomainComponent.value.updateDomains(result);
dashboardDomainComponent.value?.updateDomains(result);
}
onMounted(async () => {
@@ -150,11 +150,13 @@ onMounted(async () => {
<br/>
<ProgressBar v-if="busy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
<TableView :model="filteredDomains" :columns="columns" :busy="busy" style="max-height: 450px;" :placeholder="$t(search ? 'domains.noMatchesPlaceholder' : 'domains.emptyPlaceholder')">
<template #provider="domain">
<template #provider="{ item:domain }">
{{ DomainsModel.prettyProviderName(domain.provider) }}
</template>
<template #actions="domain">
<template #actions="{ item:domain }">
<ActionBar :actions="createActionMenu(domain)" />
</template>
</TableView>
+5
View File
@@ -6,6 +6,7 @@ const t = i18n.t;
import { ref, onMounted, useTemplateRef, inject } from 'vue';
import { Button, ProgressBar, Checkbox, InputDialog, Dialog, FormGroup, Switch } from '@cloudron/pankow';
import SaveIndicator from '../components/SaveIndicator.vue';
import Section from '../components/Section.vue';
import SettingsItem from '../components/SettingsItem.vue';
import CatchAllSettingsItem from '../components/CatchAllSettingsItem.vue';
@@ -114,6 +115,7 @@ async function onEnableIncoming() {
const customFrom = ref(false);
const customFromBusy = ref(false);
const customFromSaveIndicator = useTemplateRef('customFromSaveIndicator');
async function onToggleCustomFrom(value) {
customFromBusy.value = true;
@@ -122,9 +124,11 @@ async function onToggleCustomFrom(value) {
if (error) {
customFrom.value = !value; // revert back old value
customFromBusy.value = false;
customFromSaveIndicator.value.error();
return console.error(error);
}
customFromSaveIndicator.value.success();
customFromBusy.value = false;
}
@@ -294,6 +298,7 @@ onMounted(async () => {
<div v-html="$t('email.customFrom.description')"></div>
</FormGroup>
<Switch v-model="customFrom" @change="onToggleCustomFrom" :disabled="customFromBusy"/>
<SaveIndicator ref="customFromSaveIndicator"/>
</SettingsItem>
<SettingsItem>
+9 -3
View File
@@ -3,7 +3,7 @@
import { ref, onMounted, computed } from 'vue';
import { prettyDecimalSize, sleep } from '@cloudron/pankow/utils';
import { prettyRelayProviderName } from '../utils.js';
import { TextInput } from '@cloudron/pankow';
import { TextInput, ProgressBar } from '@cloudron/pankow';
import Section from '../components/Section.vue';
import StateLED from '../components/StateLED.vue';
import DomainsModel from '../models/DomainsModel.js';
@@ -13,6 +13,7 @@ const domainsModel = DomainsModel.create();
const mailModel = MailModel.create();
const domains = ref([]);
const busy = ref(true);
const searchFilter = ref('');
@@ -84,7 +85,10 @@ async function refreshUsage() {
onMounted(async () => {
const [error, result] = await domainsModel.list();
if (error) return console.error(error);
if (error) {
busy.value = false;
return console.error(error);
}
result.forEach(d => {
d.loadingStatus = true;
@@ -100,6 +104,7 @@ onMounted(async () => {
});
domains.value = result;
busy.value = false;
refreshStatus();
refreshUsage();
@@ -111,7 +116,7 @@ onMounted(async () => {
<div class="content">
<Section :title="$t('emails.domains.title')">
<template #header-title-extra>
<span style="font-weight: normal; font-size: 14px">({{ domains.length === 0 ? '-' : filteredDomains.length }})</span>
<span style="font-weight: normal; font-size: 14px">({{ busy ? '-' : filteredDomains.length }})</span>
</template>
<template #header-buttons>
@@ -119,6 +124,7 @@ onMounted(async () => {
</template>
<div>
<ProgressBar v-if="busy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
<div v-if="domains.length !== 0 && filteredDomains.length === 0" class="email-placeholder">{{ $t('domains.noMatchesPlaceholder') }}</div>
<a v-for="domain in filteredDomains" :key="domain.domain" :href="`#/email-domain/${domain.domain}`" class="email-domain">
<div style="display: flex; align-items: center;">
+186 -11
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, reactive, onMounted, watch, useTemplateRef, nextTick } from 'vue';
import { Button, TextInput, MultiSelect } from '@cloudron/pankow';
import { ref, reactive, computed, onMounted, watch, useTemplateRef, nextTick } from 'vue';
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
import { useDebouncedRef, prettyEmailAddresses, prettyLongDate } from '@cloudron/pankow/utils';
import MailModel from '../models/MailModel.js';
@@ -21,17 +21,98 @@ const availableTypes = [
const refreshBusy = ref(false);
const eventlogs = ref([]);
const search = useDebouncedRef('');
const page = ref(1);
const perPage = ref(10);
const types = reactive([]);
const perPage = ref(100);
const eventlogContainer = useTemplateRef('eventlogContainer');
// eslint-disable-next-line prefer-const
let types = reactive([]);
const filterFrom = ref('');
const filterTo = ref('');
const dateFilterPopover = useTemplateRef('dateFilterPopover');
const dateFilterButton = useTemplateRef('dateFilterButton');
const highlight = useDebouncedRef('', 300);
const currentMatchPosition = ref(-1);
const searching = ref(false);
const SEARCH_LOOKAHEAD_PAGES = 5;
function isMatch(eventlog, term) {
if (!term) return false;
const t = term.toLowerCase();
const fields = [
prettyEmailAddresses(eventlog.mailFrom),
prettyEmailAddresses(eventlog.rcptTo),
eventlog.mailbox,
eventlog.type,
eventlog.message,
eventlog.reason,
JSON.stringify(eventlog),
];
return fields.some(f => f && String(f).toLowerCase().includes(t));
}
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 buildFromTo() {
const from = filterFrom.value ? new Date(filterFrom.value + 'T00:00:00').toISOString() : undefined;
const to = filterTo.value ? new Date(filterTo.value + 'T23:59:59.999').toISOString() : undefined;
return { from, to };
}
async function onRefresh() {
highlight.value = '';
refreshBusy.value = true;
page.value = 1;
const [error, result] = await mailModel.eventlog(types.join(','), search.value, page.value, perPage.value);
const { from, to } = buildFromTo();
const [error, result] = await mailModel.eventlog(types.join(','), '', page.value, perPage.value, from, to);
if (error) return console.error(error);
eventlogs.value = result;
@@ -48,7 +129,8 @@ async function onRefresh() {
async function fetchMore() {
page.value++;
const [error, result] = await mailModel.eventlog(types.join(','), search.value, page.value, perPage.value);
const { from, to } = buildFromTo();
const [error, result] = await mailModel.eventlog(types.join(','), '', page.value, perPage.value, from, to);
if (error) return console.error(error);
eventlogs.value = eventlogs.value.concat(result);
@@ -58,9 +140,24 @@ 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(perPage, onRefresh);
watch(types, onRefresh);
watch(search, 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 () => {
await onRefresh();
@@ -80,12 +177,27 @@ onMounted(async () => {
<h2 class="section-header">
{{ $t('emails.eventlog.title') }}
<div>
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="search"/>
<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-model="types" :options="availableTypes" option-key="id" option-label="name" :selected-label="types.length ? $t('main.multiselect.selected', { n: types.length }) : $t('emails.typeFilterHeader')"/>
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
<Button tool secondary href="/logs.html?id=mail" target="_blank">{{ $t('main.action.logs') }}</Button>
</div>
</h2>
<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 class="section-body" ref="eventlogContainer" style="margin-top: 16px; overflow: auto; padding-top: 0" @scroll="onScroll">
<table class="eventlog-table">
<thead>
@@ -98,8 +210,8 @@ onMounted(async () => {
</tr>
</thead>
<tbody>
<template v-for="eventlog in eventlogs" :key="eventlog._id">
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
<template v-for="(eventlog, index) in eventlogs" :key="eventlog._id">
<tr :data-index="index" @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" >
<td>
<i class="fas fa-arrow-circle-left" v-if="eventlog.type === 'sent'" v-tooltip="$t('emails.eventlog.type.outgoing')"></i>
<i class="fas fa-history" v-if="eventlog.type === 'deferred'" v-tooltip="$t('emails.eventlog.type.deferred')"></i>
@@ -144,3 +256,66 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.eventlog-table {
width: 100%;
overflow: auto;
border-spacing: 0px;
table-layout: fixed;
}
.elide-table-cell {
overflow: hidden;
text-overflow: ellipsis;
}
.eventlog-table thead {
background-color: var(--pankow-body-background-color);
top: 0;
position: sticky;
z-index: 1;
}
.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: 6px;
}
.eventlog-details {
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
cursor: auto;
position: relative;
}
.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-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>

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