Compare commits

..

506 Commits

Author SHA1 Message Date
Johannes Zellner e2f4e9f30a filemanager: overwrite on upload by default for now 2024-08-20 18:31:31 +02:00
Girish Ramakrishnan 44011afd14 apps: remove port min/max tooltip
min should also be 1, otherwise you cannot go back to say port 53
2024-08-20 18:18:24 +02:00
Girish Ramakrishnan cebaa71ce1 cloudron-support: improved dns check 2024-08-20 16:52:48 +02:00
Johannes Zellner 0ed9105a05 frontend: just use vue essential linter ruleset 2024-08-19 19:27:15 +02:00
Johannes Zellner 69ecbe5ad7 filemanager: fix upload cancellation 2024-08-19 17:09:04 +02:00
Johannes Zellner a218761e99 frontend: fix various linter issues 2024-08-19 16:53:10 +02:00
Johannes Zellner 71d167d5fb Use local eslint in frontend 2024-08-19 16:12:43 +02:00
Johannes Zellner aabdea8627 New sftp addon version to not overwrite files 2024-08-19 14:38:53 +02:00
Johannes Zellner f220a1384c frontend: do not set content-length header on upload 2024-08-19 14:19:47 +02:00
Johannes Zellner e438ade08e frontend: update pankow 2024-08-19 13:30:59 +02:00
Johannes Zellner ed1d537f60 Use sftp addong 3.8.9 to fix file upload on drop 2024-08-19 12:31:10 +02:00
Johannes Zellner d59bc05f12 filemanager: support multi folder/files drops 2024-08-19 12:23:35 +02:00
Johannes Zellner 4608301f1c frontend: update dependencies 2024-08-19 11:47:43 +02:00
Girish Ramakrishnan a865320e3a 8.0.4 changes 2024-08-18 10:40:40 +02:00
Girish Ramakrishnan bc8c01900b HOST_PORT_MIN is incorrect 2024-08-17 16:32:56 +02:00
Girish Ramakrishnan 9704eefc21 backupcleaner: do not remove the backup in progress
the backup cleaner erroneously removes any "creating" state backups.
backups that are stuck are cleaned up elsewhere already (in the
backup retention logic with discardReason of "creating-too-long").
the missing backup logic is intended for any upstream lifecycle policies.
2024-08-15 15:53:31 +02:00
Girish Ramakrishnan 52cd52d83c lint 2024-08-15 15:46:19 +02:00
Girish Ramakrishnan 4a29371907 s3: sometimes message is null and only code is valid 2024-08-13 07:08:33 +02:00
Girish Ramakrishnan 1e5e4e3189 ionos: add contract-owned eu-central-3 2024-08-12 15:56:18 +02:00
Girish Ramakrishnan 041f7da59b backups: make noop upload work again 2024-08-12 10:05:14 +02:00
Girish Ramakrishnan 4dae3447d6 backups: noop provider has no location 2024-08-12 09:58:44 +02:00
Girish Ramakrishnan 7391af6f08 tail does not support doubledash it seems 2024-08-10 11:13:07 +02:00
Girish Ramakrishnan 8a640c8219 better app autoupdate logs 2024-08-10 11:04:17 +02:00
Girish Ramakrishnan 2857582f46 add note on UI timestamps 2024-08-09 14:57:50 +02:00
Johannes Zellner 1d80f03c38 dashboard: remove mailbox import/export feature 2024-08-08 15:48:47 +02:00
Johannes Zellner d7c20048fe dashboard: remove random console.log 2024-08-08 15:39:09 +02:00
Johannes Zellner cbbdb77a6e dashboard: remove hidden user import/export feature 2024-08-08 15:39:09 +02:00
Girish Ramakrishnan 2ff995aa95 filemanager: do not respond again 2024-08-08 15:20:50 +02:00
Girish Ramakrishnan 21705a0e96 volumes: /mnt/volumes is reserved 2024-08-08 14:45:50 +02:00
Girish Ramakrishnan c03da3be54 volumes: check provider instead of hostPath 2024-08-08 14:41:43 +02:00
Girish Ramakrishnan 69f48ed11a apps: do not log app logs to output 2024-08-07 15:51:04 +02:00
Johannes Zellner caa0c342a4 sftp: restore mode and owner 2024-08-01 21:44:34 +02:00
Johannes Zellner 01b4388b3c Update dependencies 2024-08-01 18:28:29 +02:00
Girish Ramakrishnan b870f98ec2 proxy-middleware: no more a middleware 2024-07-30 13:34:41 +02:00
Girish Ramakrishnan a5249102f2 proxy-middleware: just pass a string 2024-07-30 12:04:35 +02:00
Girish Ramakrishnan 5aa0c57a74 proxy-middleware: remove https and custom headers 2024-07-30 11:46:54 +02:00
Girish Ramakrishnan 053b076af0 proxy-middleware: remove via header and cookie support 2024-07-30 11:35:46 +02:00
Girish Ramakrishnan 247309e11b use constant 2024-07-30 11:00:50 +02:00
Johannes Zellner c9fe08e7b7 dashboard: also render checklist items in apps.html 2024-07-30 09:47:06 +02:00
Girish Ramakrishnan 468d4dd9b0 ami: imdsv2 support
https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/

One has to get a token now via PUT. This is because there is a bunch of
open proxies out there which blindly forwarded everything to internal network
including metadata requests. They have found that PUT requests don't cleanly
proxy and also AWS rejects token requests with X-Forwarded-For.
2024-07-27 14:48:42 +02:00
Johannes Zellner 6056ba6475 Another missing check for manifest.addons 2024-07-27 11:56:36 +02:00
Johannes Zellner 4f03a6fb58 dashboard: mailbox edit dialog is not really a form with submit action
As a form with a submit button the browser tries to be smart which will
trigger the next button tag as enter action on a textinput
2024-07-26 18:57:45 +02:00
Girish Ramakrishnan d8aa4bc5e4 filemanager: fix sending of double header
we should not proceed to notFoundHandler if proxy handled it just fine
2024-07-26 11:58:41 +02:00
Girish Ramakrishnan 06e46e0f1e 8.0.3 changes 2024-07-26 09:09:35 +02:00
Girish Ramakrishnan 731295f708 system: simplify logic 2024-07-25 17:50:50 +02:00
Girish Ramakrishnan 9399040cd3 Fix log recursion
shell.sudo logs output to stdout/stderr intentionally. It is not meant
for scripts that generate much output (basically scripts/* files).

core of the issue is that none of the log commands require to use sudo.
they can just use normal tail. only app logs requires sudo because of the
logPaths directive in the manifest.
2024-07-25 17:48:58 +02:00
Johannes Zellner 9f9fde5811 frontend: fix clear view in logs viewer 2024-07-25 17:44:20 +02:00
Johannes Zellner cbc46a8229 dashboard: support links/markdown in checklist items 2024-07-25 17:40:15 +02:00
Girish Ramakrishnan fb11997430 Add note on automatic upgrades 2024-07-25 17:09:46 +02:00
Girish Ramakrishnan b6fbc46b58 Revert "Add option to not log shell subprocess stdout+stderr"
This reverts commit 51bb2d2bc2.
2024-07-25 11:53:56 +02:00
Johannes Zellner 21de2513e7 frontend: fix all usage of file upload without multipart 2024-07-25 11:18:14 +02:00
Johannes Zellner 51bb2d2bc2 Add option to not log shell subprocess stdout+stderr
When tailing the box log file this leads to logline recursion
2024-07-25 10:22:02 +02:00
Girish Ramakrishnan 8d9043e590 logviewer: reduce it back to 100
a lot of delay with 300
2024-07-23 17:27:36 +02:00
Johannes Zellner 59c3e8817c frontend: Reduce initial logs to 300 lines only 2024-07-23 16:33:56 +02:00
Girish Ramakrishnan 3132b3035a 8.0.2 changes 2024-07-23 08:34:26 +02:00
Girish Ramakrishnan 7ebf5ca16a Bring back upload route to keep e2e happy
let's maybe remove it in next release
2024-07-23 08:28:44 +02:00
Johannes Zellner d96f132dc0 frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines 2024-07-22 23:55:47 +02:00
Girish Ramakrishnan b26ff08a3c shell: copy over code and signal values from cp object 2024-07-22 21:24:27 +02:00
Girish Ramakrishnan 44678cf5f1 sshfs: if remote copy fails, fallback to sshfs based copy
remote copy can file if there is no cp in the remote . for example,
if it was a windows server.
2024-07-22 20:53:19 +02:00
Girish Ramakrishnan 5084ee761e update postgresql conf notes 2024-07-22 18:53:51 +02:00
Girish Ramakrishnan 91f50ae949 mysql: add template custom.cnf 2024-07-22 18:53:51 +02:00
Johannes Zellner 15f04edcf1 frontend: update dependencies 2024-07-22 18:00:33 +02:00
Johannes Zellner 01945675ed Check if addons exists in database import 2024-07-22 16:45:13 +02:00
Johannes Zellner 185c16c3e2 remove apps upload api in favor of sftp container api 2024-07-22 16:20:15 +02:00
Johannes Zellner d25814b84b Remove stray console.log 2024-07-22 15:07:43 +02:00
Girish Ramakrishnan a09a3fd012 postgresql: add template custom.conf 2024-07-22 14:44:23 +02:00
Johannes Zellner 871fd83148 Use new sftp service image without multipart file upload 2024-07-22 13:29:56 +02:00
Girish Ramakrishnan dd8bc493e7 postgresql: add custom.conf include 2024-07-22 12:50:23 +02:00
Johannes Zellner 44d3baf51a dashboard: show longer pretty datetime for backups 2024-07-21 20:45:34 +02:00
Girish Ramakrishnan c85c0558b9 multipart: cleanup files after reading their contents
one idea is just use express.raw() . however, we have to implement some
file size limit there.

one case this does not handle is aborted uploads from a box.service restart.
for this rare case, a server reboot will clean up /tmp anyway.
2024-07-19 23:11:26 +02:00
Girish Ramakrishnan 7f11699fac remove urlencoded
we don't use this in our API
2024-07-19 22:44:22 +02:00
Girish Ramakrishnan 525e48ae59 json middleware is part of Express v4.16.0 2024-07-19 22:26:24 +02:00
Johannes Zellner a6369a7dde Fix linter error 2024-07-19 22:24:34 +02:00
Girish Ramakrishnan d5ea99603f backups: give is a low oomScoreAdjust to not get killed 2024-07-19 13:05:09 +02:00
Girish Ramakrishnan 083432cbfe test: add EnsureFileSizeStream test 2024-07-18 15:39:45 +02:00
Girish Ramakrishnan dbbce4160d tgz: underflow/overflow proxy stream
In tar, the entry header contains the file size. If we don't provide it those many bytes, the tar will become corrupt
Linux provides no guarantee of how many bytes can be read from a file. This is the case with sqlite and log files
which are accessed by other processes when tar is in action. This class handles overflow and underflow
2024-07-18 15:13:38 +02:00
Girish Ramakrishnan 885aac69c5 tgz: handle addEntryToPack to error 2024-07-18 14:47:31 +02:00
Girish Ramakrishnan b3c301fc2a lint 2024-07-18 13:31:29 +02:00
Girish Ramakrishnan 01deb4d285 update: updateConfig can be missing values, selectively update db 2024-07-17 08:58:43 +02:00
Girish Ramakrishnan aeddaa4566 apps: rework portBindings
ports is REST API input . Map of env var to the host port
portBinding is the database structure. Map of env var to host port, count, type etc

also, rename portCount -> count in various places to keep things consistent
2024-07-17 00:25:47 +02:00
Girish Ramakrishnan eb314ef507 lint 2024-07-16 22:07:22 +02:00
Girish Ramakrishnan 620c49cf76 Fix signature of checkForPortBindingsConflict 2024-07-16 19:31:54 +02:00
Girish Ramakrishnan 6d73dfdb40 parse port count as integer 2024-07-16 19:28:22 +02:00
Girish Ramakrishnan 232cdb8cb1 cloudron-support: do cert check before site check 2024-07-16 19:21:09 +02:00
Girish Ramakrishnan fd53174099 lint 2024-07-16 10:32:37 +02:00
Girish Ramakrishnan 9bf240d83b update: handle change in secondary domains and multiDomain flag 2024-07-16 10:32:31 +02:00
Girish Ramakrishnan 421567ff14 Add to changes 2024-07-15 21:52:04 +02:00
Girish Ramakrishnan ce05008fce setup: when activated redirect to adminFqdn 2024-07-15 21:52:01 +02:00
Girish Ramakrishnan a250cb9fe2 capitalize 2024-07-15 21:08:16 +02:00
Girish Ramakrishnan 012f8bc14e setup: show message on how to redo setup 2024-07-15 21:08:13 +02:00
Girish Ramakrishnan 11dce549bd refactor init sequence 2024-07-15 18:52:22 +02:00
Girish Ramakrishnan 5b567ac941 lint 2024-07-15 16:57:47 +02:00
Girish Ramakrishnan 5b103c78e5 lint 2024-07-15 16:54:48 +02:00
Girish Ramakrishnan bc96f9c5e5 update: match the ui in settings page 2024-07-15 16:28:00 +02:00
Girish Ramakrishnan d97d82b225 settings: do not show "skip backup" when updates is blocked
https://forum.cloudron.io/topic/12092/imho-the-skip-backup-checkbox-makes-no-sense
2024-07-15 09:48:04 +02:00
Girish Ramakrishnan e9b6002f63 s3: fix exists check 2024-07-14 22:04:12 +02:00
Girish Ramakrishnan 704999a05f backups: fix incorrect "memoryLimit must be a number" error 2024-07-14 18:21:12 +02:00
Girish Ramakrishnan ba99e3b9b7 already in setup script now 2024-07-14 17:06:13 +02:00
Girish Ramakrishnan 9adeaed1b9 support: add hidden troubleshooting section 2024-07-12 16:20:25 +02:00
Girish Ramakrishnan 10bd2e930f support: remove commented out sections 2024-07-12 14:25:11 +02:00
Girish Ramakrishnan 07396c9824 Revert "support: add route to repair apps"
This reverts commit 0bab0ed748.

It's better to somehow integrate this into the dashboard ...
2024-07-12 14:22:47 +02:00
Girish Ramakrishnan bf34b13b7f cloudron-support: add --patch 2024-07-12 11:06:06 +02:00
Girish Ramakrishnan 0bab0ed748 support: add route to repair apps 2024-07-11 18:30:29 +02:00
Girish Ramakrishnan 8754a208b1 tgz: preserve mode 2024-07-11 18:10:40 +02:00
Johannes Zellner 19100c7999 dashboard: make app update section a bit more explicit 2024-07-11 17:51:20 +02:00
Johannes Zellner d98ec77abf Update German translation 2024-07-11 16:47:39 +02:00
Johannes Zellner 34c2decd91 Remove indonesian and portuguese translation as they are way below 50% 2024-07-11 15:48:43 +02:00
Johannes Zellner 09fb4ea89f Add Indonesian (lang.id) translation name 2024-07-11 15:44:38 +02:00
Girish Ramakrishnan d6bb32aead syncer: expose as async 2024-07-10 19:10:55 +02:00
Girish Ramakrishnan 3a21191fba tgz: fix error handling 2024-07-10 19:10:24 +02:00
Girish Ramakrishnan ad4e0ba9aa tests: fix storage test 2024-07-08 22:29:45 +02:00
Girish Ramakrishnan baf598099f rsync: fix upload logic to match new upload api 2024-07-08 15:21:56 +02:00
Johannes Zellner 7d017d83d6 dashboard: do not open email client setup if disabled 2024-07-08 15:19:00 +02:00
Girish Ramakrishnan 7911780a16 const 2024-07-08 13:18:22 +02:00
Girish Ramakrishnan 1dc6b40a68 tgz: extract using tar-stream directly
we used have a fork of tar-fs. using tar-stream directly gives us
more control
2024-07-08 13:06:56 +02:00
Girish Ramakrishnan dd9e6e63ad apptask: only delete image if it is different 2024-07-08 12:59:34 +02:00
Girish Ramakrishnan 30633e7820 lint 2024-07-08 10:47:07 +02:00
Girish Ramakrishnan acfc67ed0a backuptask: typo in usage of getAvailableSize 2024-07-08 10:46:28 +02:00
Girish Ramakrishnan a99a8ef382 services: fix crash because of missing safe() 2024-07-08 10:30:10 +02:00
Girish Ramakrishnan 7aec713e6c shell: fix streaming of stdout/stderr with sudo 2024-07-08 10:09:00 +02:00
Girish Ramakrishnan 60c4dd3875 sudo: add explicit captureStdout flag 2024-07-08 09:58:25 +02:00
Girish Ramakrishnan 7d8ba8d42c tests: datalayout 2024-07-07 20:23:32 +02:00
Girish Ramakrishnan 7ff7842441 lint 2024-07-05 21:56:33 +02:00
Girish Ramakrishnan bcf497b460 translation.js -> translations.js
kept confusing my why i can't find this file! this is in line
with the rest of our code
2024-07-05 12:45:27 +02:00
Girish Ramakrishnan bf51a60986 change args of translation.translate 2024-07-05 12:42:33 +02:00
Girish Ramakrishnan 41809d1ca8 mailer: format is not used 2024-07-05 12:20:30 +02:00
Girish Ramakrishnan acb1445270 make these const 2024-07-05 11:41:07 +02:00
Girish Ramakrishnan 86530df37e mailer: add html version of test mail 2024-07-05 11:07:51 +02:00
Girish Ramakrishnan b64b513b14 Revert "use node-tar for extract"
This reverts commit 285feb4f8b.
2024-07-05 09:26:38 +02:00
Girish Ramakrishnan 285feb4f8b use node-tar for extract
we will switch over our tgz module to node-tar. Main advantage is that
it is used by npm. Currently, we have our own fork to ignore stat errors
in the other module.

unfortunately, I cannot get this to work with the create logic. It doesn't
support path modification - https://github.com/isaacs/node-tar/issues/271
so, will revert this immediately and keep this for future
2024-07-05 09:26:28 +02:00
Girish Ramakrishnan c6f4395578 Fix deprecation of Buffer.slice 2024-07-05 09:26:28 +02:00
Johannes Zellner 4981854c7f dashboard: do not duplicate app info in update section 2024-07-04 16:57:54 +02:00
Girish Ramakrishnan 65f6ff35e0 Update translations 2024-07-04 09:28:18 +02:00
Johannes Zellner d892cc5763 Add comment how to debug the openid provider 2024-07-03 11:33:58 +02:00
Johannes Zellner d122ece8e9 Attempt server side copy on sshfs via ssh exec
only so far tested agains hetzner storage boxes which apparently run BSD
unix tools
2024-07-02 19:51:34 +02:00
Girish Ramakrishnan a363e508b6 ami: disable route53
we got an email from AWS team that their policy prevents collection
of AMI credentials in AMI images
2024-07-02 16:09:36 +02:00
Girish Ramakrishnan e481606d0e lint 2024-07-02 16:09:36 +02:00
Girish Ramakrishnan a1e2c9fd08 cloudron-support: print_system 2024-07-01 14:08:55 +02:00
Girish Ramakrishnan f5931abdeb cloudron-support: print ubuntu version 2024-07-01 13:38:18 +02:00
Girish Ramakrishnan 4c9e05b08f cloudron-support: add netplan and product info 2024-07-01 08:24:01 +02:00
Girish Ramakrishnan 9c34727e88 cloudron-support: dig does not return error on SERVFAIL 2024-07-01 07:57:21 +02:00
Girish Ramakrishnan 939cd94ebb typo 2024-07-01 07:55:08 +02:00
Johannes Zellner 4a33415b06 Set notes on existing apps to empty string if NULL 2024-06-29 09:52:41 +02:00
Girish Ramakrishnan 082e659c7b disable rpcbind
rpcbind is required for NFSv2 and v3 . It seems this gets installed
by nfs-common. It was never used by us since the firewall blocks
port 111 anyways.

NFSv3 needs 2049 for NFS, 111 for portmap, 635 for mountd, 4045 for NLM, 4046 for NSM, 4049 for rquota ...

NFSv4 works better because there's just a single target port, plus the "heartbeat" of lease renewal would keep the TCP/IP session alive.

https://serverfault.com/questions/949127/nfs-client-firewall-settings-and-rpcbind
https://docs.redhat.com/en/documentation/Red_Hat_Enterprise_Linux/6/html/Storage_Administration_Guide/s2-nfs-methodology-portmap.html#s2-nfs-methodology-portmap
https://community.netapp.com/t5/Tech-ONTAP-Blogs/NFSv3-and-NFSv4-What-s-the-difference/ba-p/441316
2024-06-27 20:37:08 +02:00
Girish Ramakrishnan a8059c49e9 lint 2024-06-27 16:50:31 +02:00
Johannes Zellner f7b14b2ee8 dashboard: only show postinstall if notes are not just empty 2024-06-27 16:20:19 +02:00
Johannes Zellner 581a294af1 dashboard: give the checklist done button some space 2024-06-27 13:58:58 +02:00
Johannes Zellner 40e8ba38f0 dashboard: fix app grid item tooltip 2024-06-27 13:49:20 +02:00
Girish Ramakrishnan 65f4ec0f43 cloudron-support: check dns now and not just unbound 2024-06-27 12:58:37 +02:00
Johannes Zellner 8748ba1226 dashboard: show number of pending checklist items 2024-06-25 20:38:24 +02:00
Johannes Zellner 2ad8ee18a0 dashboard: keep checklist items up to date 2024-06-25 18:01:31 +02:00
Johannes Zellner 8b9dc5a6bf dashboard: fix eventlog for mailbox changes 2024-06-25 17:54:46 +02:00
Johannes Zellner a1a6570ee3 dashboard: show mailbox displayname in eventlog 2024-06-25 17:27:37 +02:00
Girish Ramakrishnan 6c68f7da2e apps: updateTime should be null if never updated
"TIMESTAMP NULL" is an attribute modifier to make the column nullable.
Without it, if you assign null, the timestamp becomes the current time!
2024-06-25 17:24:02 +02:00
Girish Ramakrishnan ccd5f6c2e5 app: move the installation time down 2024-06-25 17:24:02 +02:00
Johannes Zellner 73b20ae809 dashboard: do not show sso info in postinstall dialog 2024-06-25 16:56:33 +02:00
Johannes Zellner a4dd6cc928 dashboard: remove checkbox in background image branding 2024-06-25 16:26:06 +02:00
Girish Ramakrishnan 6f37bde55d import: add prefix support
it is incorrect to ignore the prefix. the keys (for s3) and permissions (managed mounts)
might work only inside the prefix.
2024-06-25 13:41:21 +02:00
Girish Ramakrishnan 8f1f3cea18 doc: add import fields 2024-06-25 13:41:21 +02:00
Johannes Zellner f715e21306 oidc: raise login event on consent not login form itself 2024-06-25 13:24:46 +02:00
Girish Ramakrishnan d9b478cf1f rename setupStorage to setupManagedStorage 2024-06-25 13:06:40 +02:00
Girish Ramakrishnan 36a768eb60 backups: document structure of backupConfig
I keep forgetting this, let's write this up once and for all
2024-06-25 13:06:40 +02:00
Johannes Zellner 0ebe6e545d dashboard: fix mobile dark-mode for back action 2024-06-25 12:26:18 +02:00
Johannes Zellner 5fb295e044 frontend: Update dependencies 2024-06-25 12:18:54 +02:00
Johannes Zellner be7e11a4f6 frontend: use new busy state on the directoryview 2024-06-25 11:59:14 +02:00
Girish Ramakrishnan d13bf9ac74 eventlog: fix display of directoryserver login event 2024-06-24 21:42:03 +02:00
Girish Ramakrishnan e909b6e643 mail: mail manager role cannot change server location 2024-06-24 21:31:29 +02:00
Girish Ramakrishnan 9555a93ddc redis: don't send password when noPassword 2024-06-24 21:25:48 +02:00
Johannes Zellner 1d9ad35019 dashboard: show postinstall message for admin notes unless set 2024-06-24 19:59:02 +02:00
Johannes Zellner 78aee78d9c dashboard: better login page background image label 2024-06-24 19:34:32 +02:00
Johannes Zellner 4b96d5879c dashboard: show who and when a checklist item was acked 2024-06-24 19:11:38 +02:00
Johannes Zellner 20396a8c7d Adjust checklist item api to support audits trail 2024-06-24 19:09:03 +02:00
Johannes Zellner 8510b12841 dashboard: show/hide done checklist items 2024-06-24 16:53:57 +02:00
Girish Ramakrishnan 345f9541fe mongodb: do not apply memory limit when no avx 2024-06-23 21:06:30 +02:00
Johannes Zellner c1c864ced7 dashboard: show app install time in info section 2024-06-22 15:50:50 +02:00
Girish Ramakrishnan 7a440a32d1 ldap connector: make auto-create true by default 2024-06-22 10:35:04 +02:00
Johannes Zellner ef1431f89b dashboard: sort by location in app list by default 2024-06-21 21:18:22 +02:00
Johannes Zellner 57cf0ec074 dashboard: improve app list view layout 2024-06-21 21:17:05 +02:00
Girish Ramakrishnan d795507ddd Update translations 2024-06-21 18:07:00 +02:00
Girish Ramakrishnan c3aafb2979 even more changes 2024-06-21 17:09:17 +02:00
Johannes Zellner 93d4472932 dashboard: improve list view column headers 2024-06-21 16:54:32 +02:00
Johannes Zellner 69934be88c frontend: remove home icon in filemanager 2024-06-21 16:45:56 +02:00
Johannes Zellner 8638bfb30b dashboard: show full mailbox address in eventlog 2024-06-21 15:48:06 +02:00
Johannes Zellner 5b3d6a3957 dashboard: do not show number of active app filters 2024-06-21 15:39:04 +02:00
Johannes Zellner 94dd0644d0 frontend: Mention /app/data in filemanager breadcrumbs 2024-06-21 15:39:04 +02:00
Girish Ramakrishnan f089329e12 more changes 2024-06-21 15:21:59 +02:00
Johannes Zellner 8554d374c9 dashboard: eventlog fix cpu quota display 2024-06-21 14:49:11 +02:00
Johannes Zellner 424ec1c90d dashboard: show pretty memory limits in eventlog 2024-06-21 14:43:10 +02:00
Johannes Zellner ce2f1b4170 dashboard: fix colspan typo 2024-06-21 14:42:58 +02:00
Johannes Zellner ce1146a9ef frontend: set dark background as early as possible to avoid white flashing 2024-06-21 10:57:21 +02:00
Johannes Zellner f065821587 frontend: slightly improve local development 2024-06-21 10:57:01 +02:00
Johannes Zellner 18c518f385 dashboard: improve postinstall dialog header 2024-06-20 15:57:45 +02:00
Johannes Zellner 6ba1953acb Revert "lint"
This reverts commit 36887abf88.
2024-06-20 15:47:49 +02:00
Johannes Zellner 324ee4641f dashboard: ensure configure button is not overlapping the checklist indicator 2024-06-20 14:58:45 +02:00
Johannes Zellner 9ee4490498 frontend: also update the package lock file 2024-06-20 13:36:36 +02:00
Johannes Zellner 0fa32c9572 frontend: update pankow for dark mode fixes 2024-06-20 13:36:13 +02:00
Johannes Zellner b3e5563e15 dashboard: checklist bubble goes right 2024-06-20 13:15:15 +02:00
Johannes Zellner 55038dee51 frontend: update dependencies 2024-06-20 13:06:41 +02:00
Girish Ramakrishnan b54eaf2964 more changes 2024-06-20 13:02:54 +02:00
Johannes Zellner 98e97a0f9b dashboard: show pending admin checklist in apps list/grid 2024-06-18 16:37:21 +02:00
Johannes Zellner f15b4a4f4b dashboard: Better state handling for app info notes 2024-06-18 15:49:52 +02:00
Johannes Zellner bd7641f502 frontend: fix autoscroll in logviewer 2024-06-17 18:25:50 +02:00
Girish Ramakrishnan 2d04ec2308 appstore: check response is an image 2024-06-15 17:31:49 +02:00
Girish Ramakrishnan ba0ab68f50 appstore: validate the id and the version 2024-06-15 17:11:11 +02:00
Girish Ramakrishnan 825fe21bd9 lint 2024-06-15 17:03:54 +02:00
Girish Ramakrishnan 072ca73259 Update manifestformat for more permissive ids 2024-06-15 12:38:53 +02:00
Girish Ramakrishnan b333a136e8 cloudron-support: some newlines 2024-06-14 17:08:23 +02:00
Girish Ramakrishnan e34cf7fd77 cloudron-support: more fixes to --recreate-docker 2024-06-14 13:04:40 +02:00
Girish Ramakrishnan 1c7099b3f0 typo 2024-06-14 11:45:15 +02:00
Girish Ramakrishnan d74ee441ac oidc: add doc link to various endpoints 2024-06-14 11:37:42 +02:00
Girish Ramakrishnan 7bad90009e cloudron-support: better recreate-docker 2024-06-14 11:02:53 +02:00
Girish Ramakrishnan 424bc588f6 cloudron-support: implement --recreate-docker 2024-06-13 18:51:11 +02:00
Girish Ramakrishnan 852e1e1687 cloudron-support: implement --recreate-containers 2024-06-13 17:55:22 +02:00
Girish Ramakrishnan 649c06b641 notification: do not send login notification for external users 2024-06-13 16:55:35 +02:00
Girish Ramakrishnan 6b4df0bd65 lint 2024-06-13 16:55:35 +02:00
Johannes Zellner e67324b05c Update translations 2024-06-13 16:02:13 +02:00
Johannes Zellner d688f5e080 dashboard: slightly better admin notes edit handling 2024-06-13 15:48:35 +02:00
Johannes Zellner c3f9d688f1 frontend: more dark mode fixes 2024-06-13 12:19:03 +02:00
Johannes Zellner 7affc6e987 frontend: initial dark mode for filemananger 2024-06-12 19:57:21 +02:00
Johannes Zellner 9f26608681 frontend: remove unused css 2024-06-12 19:37:10 +02:00
Girish Ramakrishnan d34b102e52 mandatory2fa: fix workflow when using external LDAP
* Always allow the mandatory 2fa setting to be saved
* Show warning for user if they have no 2fa setup and if not external 2fa
* If they get locked out anyway, they have to use CLI tool
* redirect for mandatory 2fa only if not external 2fa as well
2024-06-12 12:26:40 +02:00
Girish Ramakrishnan 077f95049e test: user directory profile route 2024-06-12 11:11:56 +02:00
Girish Ramakrishnan b570f2f77d userdirectory: add eventlog entry 2024-06-12 10:52:10 +02:00
Girish Ramakrishnan b4e7e394c3 split routes and model code into user-directory.js 2024-06-12 10:49:01 +02:00
Girish Ramakrishnan e1f87161a8 mandatory 2fa: revoke oidc sessions of non-2fa users 2024-06-12 10:16:49 +02:00
Johannes Zellner 57bf3709f3 proxyauth: also preserve request uri for proxyauth addon apps 2024-06-11 19:15:58 +02:00
Johannes Zellner 9d258d33cf Use 127.0.0.53 as a resolver for nginx 2024-06-11 18:59:19 +02:00
Johannes Zellner 62e322c451 proxyauth: stash path and query for further use in the session 2024-06-11 18:59:19 +02:00
Johannes Zellner 9a04ee2d1f frontend: update pankow for inputdialog fixes 2024-06-11 18:59:19 +02:00
Girish Ramakrishnan 5852fac71a backups: validate mountOptions is an object for managed providers 2024-06-11 14:40:54 +02:00
Johannes Zellner f315a378dc frontend: also selectively show filemanager in terminal if localstorage addon exists 2024-06-11 14:01:57 +02:00
Johannes Zellner dcee792aaa frontend: show filemanager,terminal and restart buttons only where it makes sense 2024-06-11 14:01:57 +02:00
Girish Ramakrishnan d0df897f93 typo 2024-06-11 14:01:13 +02:00
Johannes Zellner 915e3ecc94 dashboard: Show eventlog source IP in details 2024-06-11 12:05:58 +02:00
Johannes Zellner 76dadd1f8b dashboard: do not show eventlog source ip - too noisy with ipv6 2024-06-11 11:40:04 +02:00
Johannes Zellner 73fdcae916 dashboard: Show eventlog source in app view 2024-06-11 11:36:49 +02:00
Girish Ramakrishnan 941162a05f networking: ipv4 configuration can be missing 2024-06-10 19:18:11 +02:00
Johannes Zellner 22b8ec6144 frontend: fixes to use pankow-viewers 2024-06-10 11:59:34 +02:00
Girish Ramakrishnan a0c7f3f896 pin superagent to 9.0.1
9.0.2 broke basic ipv6 queries -https://github.com/ladjs/superagent/pull/1805
2024-06-10 11:28:33 +02:00
Girish Ramakrishnan 692be297b3 app proxy: protect code accessing containerId 2024-06-10 11:23:29 +02:00
Girish Ramakrishnan db3eabcd2f mail: haraka limits plugin fix
https://github.com/haraka/haraka-plugin-limit/pull/63
2024-06-10 09:43:14 +02:00
Johannes Zellner fee78bb488 frontend: ask user if terminal should be really closed on ctrl+w 2024-06-09 15:35:10 +02:00
Johannes Zellner dda6f43b8a frontend: update dependencies 2024-06-09 15:27:21 +02:00
Girish Ramakrishnan b5fad74ea0 Update packages 2024-06-08 22:37:20 +02:00
Girish Ramakrishnan ef42106a16 redis: cleanup tmp rdb files 2024-06-08 22:34:30 +02:00
Girish Ramakrishnan bba1922120 dockerproxy: fix test 2024-06-08 22:26:34 +02:00
Girish Ramakrishnan f386c326e2 apptask: only move app uses localstorage addon 2024-06-06 16:26:02 +02:00
Girish Ramakrishnan 3b26f6f5ea use optional chaining 2024-06-06 15:19:19 +02:00
Girish Ramakrishnan 52701e1173 add note 2024-06-06 15:16:04 +02:00
Girish Ramakrishnan 3c7d24916c backups: show generic error in the dialog 2024-06-06 11:41:33 +02:00
Girish Ramakrishnan 4fac0cb535 const fixes 2024-06-06 11:33:42 +02:00
Girish Ramakrishnan 00f6ef7603 backups: cannot change config in demo mode 2024-06-06 11:32:15 +02:00
Girish Ramakrishnan 556b9fe20c test: fix updatechecker test 2024-06-03 19:34:22 +02:00
Girish Ramakrishnan 3dcd0975f7 test: fix various routes tests
* system/disks routes is gone
* provision routes now return 405 instead of 409 when re-setup/re-activated
2024-06-03 19:27:23 +02:00
Girish Ramakrishnan db7e88e302 Update translations 2024-06-03 15:35:39 +02:00
Girish Ramakrishnan 9c5fb2823c Update test packages 2024-06-03 10:38:53 +02:00
Girish Ramakrishnan d12b2ae2db Revert "database: change db fields"
This reverts commit 0227ae1d96.

unclear why this doesn't work now
2024-06-03 10:37:59 +02:00
Girish Ramakrishnan 449d68122b progressstream: add stats func 2024-05-31 16:55:41 +02:00
Girish Ramakrishnan 0227ae1d96 database: change db fields 2024-05-31 16:55:41 +02:00
Girish Ramakrishnan 265e58e5cb ovh: add rbx region 2024-05-29 16:53:04 +02:00
Girish Ramakrishnan 9054f30aef lint 2024-05-25 13:42:44 +02:00
Girish Ramakrishnan 36887abf88 lint 2024-05-25 13:10:53 +02:00
Girish Ramakrishnan 4ca5fcf472 postgresql: set process limits 2024-05-23 21:50:47 +02:00
Girish Ramakrishnan c4b01dea22 mail: rename fields in spam acl 2024-05-23 17:04:01 +02:00
Girish Ramakrishnan 6d4cc4a6b8 const fixes 2024-05-23 10:58:59 +02:00
Girish Ramakrishnan 4229e9921c blacklisted -> blocked 2024-05-23 09:53:47 +02:00
Johannes Zellner 92b6a7e335 dashboard: do not crash if sysinfo cache still contains removed volume info 2024-05-22 11:39:19 +02:00
Girish Ramakrishnan cb8731b915 syslog: also replace CR (\r) 2024-05-21 16:48:29 +02:00
Johannes Zellner 0c80b7af1d dashboard: show checklist items in postinstall dialog 2024-05-20 18:13:17 +02:00
Girish Ramakrishnan 4ce9c46215 dyndns: update mail server location 2024-05-17 13:23:47 +02:00
Girish Ramakrishnan bbf402368f lint: const 2024-05-17 13:18:53 +02:00
Girish Ramakrishnan 37d1dc7c6d add to changes 2024-05-16 14:56:57 +02:00
Johannes Zellner a677dc3981 Fixup crontab tests 2024-05-15 15:17:42 +02:00
Johannes Zellner 77163cc1b2 remove legacy system/disks route 2024-05-15 14:34:30 +02:00
Girish Ramakrishnan 5d41a84fec add comment 2024-05-15 14:14:05 +02:00
Johannes Zellner 890de53b0a syslog: handle potential multiline syslog input 2024-05-15 13:45:32 +02:00
Johannes Zellner a1f2b5b696 frontend: update dependencies 2024-05-13 17:49:19 +02:00
Girish Ramakrishnan 6eda037544 lint: const fixes 2024-05-13 17:02:20 +02:00
Girish Ramakrishnan eb5b8b42dc updater: delete any old dirs from failed updates 2024-05-13 17:00:58 +02:00
Girish Ramakrishnan 4a5022d14d lint: const fixes 2024-05-13 08:43:33 +02:00
Girish Ramakrishnan 54c6f9c4f8 logs: do not push empty lines 2024-05-13 08:37:49 +02:00
Johannes Zellner dee60e9958 dashboard: improve checklist handling 2024-05-11 11:26:46 +02:00
Johannes Zellner bbefa38355 dashboard: show who installed an app in the eventlog 2024-05-10 17:56:05 +02:00
Girish Ramakrishnan 6681f2e5c8 netcup: dns fixes 2024-05-04 18:37:40 +02:00
Johannes Zellner 1728756dc4 dashboard: give appstore tiles more horizontal space 2024-05-03 12:30:26 +02:00
Johannes Zellner 1f0860e45d oidc: hide login form while initializing the view 2024-05-03 11:48:25 +02:00
Johannes Zellner 9eb91a3ae9 Update pankow 2024-05-02 18:18:34 +02:00
Johannes Zellner ad50ea5aee frontend: remove primeicons 2024-05-02 15:18:48 +02:00
Johannes Zellner 73045fd7fc frontend: update dependencies 2024-05-02 15:07:07 +02:00
Johannes Zellner 11aeccc822 frontend: remove primevue from terminal 2024-05-02 15:05:04 +02:00
Johannes Zellner 310a8c1c63 frontend: remove primevue from logsviewer 2024-05-02 13:08:56 +02:00
Johannes Zellner 23153e5b86 frontend: filemanager is now without primevue 2024-05-01 14:51:16 +02:00
Johannes Zellner 130d8a1ba0 Frontend: stop using primevue confirm service 2024-05-01 14:17:44 +02:00
Johannes Zellner 8d9ecf3352 frontend: remove unused InputText 2024-05-01 13:12:54 +02:00
Johannes Zellner 6080cfa351 frontend: replace Dialogs with pankow Dialog 2024-05-01 13:11:52 +02:00
Johannes Zellner 4e04b2075f frontend: use pankow InputDialog from prompts 2024-05-01 12:53:45 +02:00
Johannes Zellner 9f415826fd frontend: remove unused margins 2024-05-01 12:53:45 +02:00
Girish Ramakrishnan 54d92b8bf7 backups: uploadPartSize only makes sense for s3 2024-05-01 12:39:32 +02:00
Girish Ramakrishnan f1e8b91f61 backups: remove limit object from storage config
this is causing UI to get confused
2024-05-01 12:06:04 +02:00
Girish Ramakrishnan a1bd1a0fa1 domains: add list/get/del test as normal user 2024-04-30 10:36:12 +02:00
Girish Ramakrishnan b142cd5039 domains: when listing, send all fields
also remove the certificate key from responses
2024-04-30 09:47:50 +02:00
Girish Ramakrishnan b548856c29 domains: remove wildcard field check
it is part of tlsConfig object
2024-04-30 09:06:04 +02:00
Girish Ramakrishnan a0df52000a typo 2024-04-29 15:51:16 +02:00
Girish Ramakrishnan e98a1a9767 docker container can use system dns
only mail container needs unbound for dnsbl
2024-04-29 15:48:30 +02:00
Johannes Zellner ad2eaff60e frontend: use pankow Menu in filemanger 2024-04-29 15:39:56 +02:00
Johannes Zellner 3df7b74f65 dashboard: preserve path when relogin is required 2024-04-29 15:22:01 +02:00
Girish Ramakrishnan 67c1b2cb71 installer: remove custom nginx upgrade logic 2024-04-29 14:23:19 +02:00
Girish Ramakrishnan 6c0e84a31d installer: remove verbose extract 2024-04-29 14:14:36 +02:00
Girish Ramakrishnan c49a440211 init-ubuntu: resolvconf is not needed anymore
unbound is still needed since it's running but not for resolv.conf
2024-04-29 13:22:19 +02:00
Girish Ramakrishnan caedf6a8e7 remove resolvconf and enable systemd-resolved 2024-04-29 13:19:52 +02:00
Girish Ramakrishnan 203330d1b8 lint: const 2024-04-29 13:05:07 +02:00
Girish Ramakrishnan c8d66384c7 domains: check if wildcard is boolean in tlsConfig 2024-04-29 12:52:12 +02:00
Girish Ramakrishnan 74447d2690 lint 2024-04-29 12:49:20 +02:00
Girish Ramakrishnan b66ddedc86 domains: remove unused wildcard check 2024-04-29 12:45:57 +02:00
Girish Ramakrishnan 8df97de8c6 Ubuntu 24.04
* update docker to 26.0.1
* cloudron-syslog needs to have correct perms for fifo socket
2024-04-29 11:07:10 +02:00
Girish Ramakrishnan cd5cae33ce dns: switch over to systemd for the host
this changes unbound to listen to 127.0.0.150 (150 is roman CL)

we cannot only bind on docker bridge because unbound is relied
upon for the initial domain setup. docker itself is only initialized
when the platform initializes
2024-04-29 11:06:03 +02:00
Girish Ramakrishnan 608ce53e7d scripts: remove unused cloudron-logs 2024-04-29 10:21:33 +02:00
Johannes Zellner d2ae6c2353 dashboard: grid view is the default 2024-04-29 09:32:00 +02:00
Johannes Zellner 7eda1136ea oidc: starting with new .json model files is not worth a log line 2024-04-29 09:19:37 +02:00
Girish Ramakrishnan a756fa9e9b remove dead code 2024-04-28 10:52:30 +02:00
Girish Ramakrishnan afb5e5ac5d add to changes 2024-04-27 19:27:11 +02:00
Girish Ramakrishnan efa1acddd4 dns: unregister domains if type is disabled 2024-04-27 18:43:31 +02:00
Girish Ramakrishnan e00db115ad restore: fix crashes 2024-04-27 12:46:37 +02:00
Girish Ramakrishnan 366f247910 oidc: only start in set callback 2024-04-27 11:51:10 +02:00
Girish Ramakrishnan 2a6368af60 remove usage of constants.DASHBOARD_SUBDOMAIN 2024-04-27 11:10:24 +02:00
Girish Ramakrishnan 5420630453 oidc: start the server when dashboard domain is set
the activation logic has changed to use oidc flow. this requires
the oidc server to be started and available. otherwise, the redirection
after owner creation fails.
2024-04-27 11:02:50 +02:00
Girish Ramakrishnan 4e39eb89fd const 2024-04-27 10:48:23 +02:00
Girish Ramakrishnan a783944700 notfound: better error message for IP 2024-04-26 21:25:33 +02:00
Girish Ramakrishnan 8a987db177 provision: add route to detect ipv4 and ipv6 2024-04-26 20:53:32 +02:00
Girish Ramakrishnan 834a7d0f55 rename setupdns to setup 2024-04-26 20:32:23 +02:00
Girish Ramakrishnan 051bcb7819 rename setup to activation 2024-04-26 20:26:57 +02:00
Girish Ramakrishnan 126587ba82 lint: constness 2024-04-26 20:09:36 +02:00
Girish Ramakrishnan 860ebcbe6a provision: add activation guard 2024-04-26 20:06:56 +02:00
Girish Ramakrishnan 25f395ed63 Update DNS A record text 2024-04-26 19:13:34 +02:00
Girish Ramakrishnan 2da361a1f2 waitfordns: resolve and check against NS' IPv6 address 2024-04-26 19:12:53 +02:00
Johannes Zellner 4e363dc77a frontend: move more UI elements to pankow 2024-04-26 17:11:02 +02:00
Girish Ramakrishnan 23e20b9b83 waitfordns: better debugs 2024-04-26 14:46:01 +02:00
Girish Ramakrishnan e70a6ffbb9 zoneName filter is gone 2024-04-26 14:25:02 +02:00
Girish Ramakrishnan cab236123f reindent branding page 2024-04-26 14:10:12 +02:00
Johannes Zellner cab7e0d8a3 Fixup indentation in setup pages 2024-04-26 14:07:53 +02:00
Girish Ramakrishnan 2f425f8119 provision: add ipv6 config 2024-04-26 12:20:15 +02:00
Girish Ramakrishnan 017e46fa0f rename sysinfo to ipv4Config 2024-04-26 12:20:15 +02:00
Johannes Zellner 9efcd9060e frontend: Use pankow breadcrumbs 2024-04-25 19:35:54 +02:00
Girish Ramakrishnan abdd5d3e0e eslint: upgrade 2024-04-25 19:00:37 +02:00
Girish Ramakrishnan cf40346e1a eslint: add node globals 2024-04-25 18:26:35 +02:00
Girish Ramakrishnan b6d80fb443 eslint.config.js for the new eslint 2024-04-25 18:14:06 +02:00
Girish Ramakrishnan f6e4f1aefc network: ipv4 can be disabled 2024-04-25 15:50:42 +02:00
Girish Ramakrishnan dbf66b8e89 fix indent 2024-04-25 15:06:17 +02:00
Girish Ramakrishnan 53ad3902ac remove unused function 2024-04-25 15:06:17 +02:00
Girish Ramakrishnan cae2bfbdc2 domains: add desec provider 2024-04-24 21:29:42 +02:00
Girish Ramakrishnan 58d6142460 ovh: storage location has changed 2024-04-24 16:37:41 +02:00
Johannes Zellner 2ca4838ac7 dashboard: app config has new info tab 2024-04-23 17:07:30 +02:00
Girish Ramakrishnan 3787f90283 appstore: bump timeout to 60s instead of 30s
this timeout is hit on some servers (which have some networking
issue). unfortunately, this triggers a bug in superagent -
https://github.com/ladjs/superagent/issues/1801
2024-04-23 11:41:51 +02:00
Girish Ramakrishnan 9064375e25 cloudron-support: remove bad nginx configs 2024-04-23 10:00:06 +02:00
Girish Ramakrishnan 033036bd1a cloudron-support: check service uptime 2024-04-22 17:45:43 +02:00
Girish Ramakrishnan 5d74d80829 cloudron-support: check if node binary exists 2024-04-22 16:41:26 +02:00
Girish Ramakrishnan 88231e3d35 sftp: add rate limit 2024-04-21 21:04:00 +02:00
Girish Ramakrishnan 1aa683aeab add comments on the rate limits 2024-04-21 21:02:55 +02:00
Girish Ramakrishnan c2326bc5cc oidc: add rate limit for login requests 2024-04-21 20:58:12 +02:00
Girish Ramakrishnan 55db3ae517 Fix link 2024-04-21 16:48:40 +02:00
Johannes Zellner 4b0dbf0183 dashboard: localstorage has no concept of booleans 2024-04-20 22:02:05 +02:00
Johannes Zellner 2725e001a5 frontend: update dependencies 2024-04-20 14:10:34 +02:00
Johannes Zellner 02a0f65e4b dashboard: update fontawesome and sass 2024-04-20 13:41:04 +02:00
Johannes Zellner 9fd964022e dashboard: html-templates is gone 2024-04-20 13:38:47 +02:00
Johannes Zellner ec7dabc1c7 oidc: also allow login on aliased app domains 2024-04-19 19:03:23 +02:00
Girish Ramakrishnan 95eeb9ce93 s/your/the 2024-04-19 18:33:17 +02:00
Girish Ramakrishnan d137cdf881 update cron module
CronJob -> CronJob.from
CronJob(time) -> CronTime
2024-04-19 18:31:47 +02:00
Girish Ramakrishnan a926a3e8a8 update google cloud modules
https://github.com/googleapis/nodejs-storage/releases/tag/v7.0.0
2024-04-19 18:09:17 +02:00
Girish Ramakrishnan e8b3516d34 update marked 2024-04-19 18:00:02 +02:00
Girish Ramakrishnan 54e5e0cb7e update commander
https://github.com/tj/commander.js/releases/tag/v12.0.0
2024-04-19 17:56:15 +02:00
Girish Ramakrishnan baa4620523 update jsdom
https://github.com/jsdom/jsdom/releases/tag/24.0.0
2024-04-19 17:53:26 +02:00
Girish Ramakrishnan fcd1532a4d update jose
breaking changes: https://github.com/panva/jose/releases/tag/v5.0.0
2024-04-19 17:45:26 +02:00
Girish Ramakrishnan 66b768b176 Update packages 2024-04-19 17:40:42 +02:00
Girish Ramakrishnan eeae8c92d0 nodejs: update to 20.12.2 2024-04-19 17:40:42 +02:00
Johannes Zellner d35bfbb0fd dashboard: finish checklist display
pending showing acknowledged items later
2024-04-19 14:32:34 +02:00
Johannes Zellner 4516b0c57c Do not return but continue in a loop 2024-04-19 14:29:41 +02:00
Johannes Zellner 49243822af dashboard: show app checklist 2024-04-19 14:17:54 +02:00
Johannes Zellner 16521d5434 Fix updateChecklist usage 2024-04-19 12:15:13 +02:00
Girish Ramakrishnan 1afa2e87ec mailserver: a056bcfd broke mail server restart
after proxying, we never restarted the mail server

also add note that restart has to reconfigure
2024-04-19 10:48:08 +02:00
Girish Ramakrishnan 18ec929501 lint 2024-04-19 10:48:08 +02:00
Johannes Zellner 7d6636bb54 Only add checklist items if they apply due to sso state 2024-04-18 16:05:38 +02:00
Johannes Zellner 3c7e6b59f0 Add initial support for apps.checklist 2024-04-17 16:54:54 +02:00
Johannes Zellner daa8a60da2 oidc: Inject currently hardcoded CLOUDRON_OIDC_PROVIDER_NAME env var
This is designed to be used in the packages for the login button:
"Login with ${CLOUDRON_OIDC_PROVIDER_NAME}"
2024-04-17 15:06:22 +02:00
Johannes Zellner f231d51d0b Make oidc authproxy login button translatable 2024-04-17 14:21:07 +02:00
Girish Ramakrishnan 308f315ed5 troubleshoot: print box version 2024-04-17 09:26:32 +02:00
Girish Ramakrishnan a572374ad7 updatechecker: deep compare update object from appstore
When 'changelog' , 'unstable' fields change the box code is not
getting it.
2024-04-16 19:30:14 +02:00
Girish Ramakrishnan 1cf315634c appstore: check the type of unstable field 2024-04-16 19:19:27 +02:00
Johannes Zellner b0d2bdbad9 Make it login with cloudron in authproxy 2024-04-16 14:56:18 +02:00
Johannes Zellner 255fb0cac0 proxyauth: show intermediate login button page 2024-04-16 13:43:12 +02:00
Johannes Zellner c3be0018fe proxyauth: send user to oidc login instead of /login 2024-04-16 11:29:00 +02:00
Girish Ramakrishnan 37e2269387 import: add seal option 2024-04-15 22:20:04 +02:00
Girish Ramakrishnan 5dbe2ce2e4 cifs: enable seal by default 2024-04-15 22:00:28 +02:00
Johannes Zellner 1008ec4fa1 proxyauth: remove basic auth login form 2024-04-15 18:52:07 +02:00
Johannes Zellner d36d1cf1da dashboard: wait on refreshApp on submitting notes 2024-04-15 17:38:02 +02:00
Johannes Zellner 21d7438bbe proxyauth: user OpenID instead of basic auth 2024-04-15 15:59:16 +02:00
Girish Ramakrishnan caf1c37171 motd: mention troubleshooting tool 2024-04-15 13:46:44 +02:00
Girish Ramakrishnan 0a748ac78a better AVX error message 2024-04-15 10:10:13 +02:00
Johannes Zellner 76c4002a04 oidc: Add profile picture claim 2024-04-14 12:05:45 +02:00
Johannes Zellner 201a07f717 dashboard: Some dark mode fixes for list view 2024-04-12 19:51:46 +02:00
Johannes Zellner 5b2eb51511 dashboard: show app count in list view 2024-04-12 14:12:03 +02:00
Johannes Zellner 36ab5800a3 oidc: enable CORS for internal apps 2024-04-11 19:10:29 +02:00
Girish Ramakrishnan a79486275e tldjs: update rules using --tldjs-update-rules
the rules we use are 6 years old! we still need to use the public
suffix list to figure out the zone name by default for ease of use.

Domains like co.uk will only appear in the suffix list and not in the
tld list (https://www.iana.org/domains/root/db)

To verify if the list is updated:

node -e "console.log(require('tldjs').getDomain('whatever.framer.ai'))"

The above will output "whatever.framer.ai"
2024-04-11 18:28:52 +02:00
Girish Ramakrishnan 6dc70a8f3b dashboard: tld and angular-tld are not used 2024-04-11 18:08:21 +02:00
Johannes Zellner 8e990e4e0a dashboard: Set app info as default configure tab 2024-04-11 13:45:34 +02:00
Girish Ramakrishnan f11becfcc8 async'ify
crazy this has gone unnoticed for so long!
2024-04-10 18:52:39 +02:00
Johannes Zellner 8d04374764 dashboard: info tab translations 2024-04-10 18:48:20 +02:00
Johannes Zellner 87ae95aa4f Add per-app notes feature 2024-04-10 18:34:58 +02:00
Girish Ramakrishnan 0fa1ec44b1 app: add description for memory 2024-04-10 18:28:32 +02:00
Girish Ramakrishnan b4e4f26361 Rework cpuShares into cpuQuota
cpuShares is the relative weight wrt other apps. This is used when
there is contention for CPU. If we want this, maybe we implement
a UI where we show all the apps and let the user re-order them.
As it stands, it is confusing.

cpuQuota is a more straightforward "hard limit" of the CPU% that you
want the app to consume.

Can be tested with : stress -c 8 -t 20s
2024-04-10 18:25:14 +02:00
Girish Ramakrishnan 2afaf1f36d more changes 2024-04-10 12:52:42 +02:00
Girish Ramakrishnan f236213356 backups: memory max is RAM 2024-04-10 12:48:07 +02:00
Girish Ramakrishnan efd0be5e2c services: send the default memory limit 2024-04-10 12:42:25 +02:00
Johannes Zellner 6612f48d0a dashboard: make filter bar persistent and inline instead of popover 2024-04-10 12:24:56 +02:00
Girish Ramakrishnan f1679f1614 compute app excess based on RAM*2 2024-04-10 12:12:49 +02:00
Girish Ramakrishnan 8b7dca00af app memory: make slider go till RAM
anything above RAM is useless
2024-04-10 12:12:49 +02:00
Johannes Zellner 59fa26b0fb dashboard: we hardly use warning button colour 2024-04-09 19:55:47 +02:00
Johannes Zellner 7a92222050 dashboard: show active filter numbers 2024-04-09 19:55:07 +02:00
Girish Ramakrishnan be2775e12e memoryLimit: redefine to not include swap
Currently, we allocate 50% as RAM and 50% as swap. The manifest is
usually quite conservative on memory values. This means that we set
up a system where the app is applying memory pressure almost immediately.
This then swaps things randomly and increases cpu usage (kswapd shows
up in the profile).

To rethink the whole situation: we should not cap apps with a swap limit at all.
The memory hard limit is what is important. By redefining memoryLimit , we are
doubling every container's memory and it's good that we over allocate this.
2024-04-09 18:59:40 +02:00
Girish Ramakrishnan 6c3f8b9b84 various changes 2024-04-09 18:48:46 +02:00
Johannes Zellner f02157857c dashboard: add / eventhandler for search 2024-04-09 15:41:50 +02:00
Girish Ramakrishnan 470b0d6be7 update some modules 2024-04-09 15:31:46 +02:00
Girish Ramakrishnan 2b1b304c6e backup/import/restore: fix crash with root path calcuation
rootPath was calculated before the arguments were validated
2024-04-09 13:53:48 +02:00
Johannes Zellner 5460a64951 dashboard: Make app list columns sortable 2024-04-09 13:51:57 +02:00
Girish Ramakrishnan 62faf616c5 import: acceptSelfSignedCerts is validated at provider 2024-04-09 13:24:33 +02:00
Girish Ramakrishnan 3f2f4c7c6b restore: acceptSelfSignedCerts is validated by provider 2024-04-09 13:20:01 +02:00
Girish Ramakrishnan 5e49a33e8f backups: rootPath is needed only when testing storage 2024-04-09 13:03:31 +02:00
Girish Ramakrishnan 5fb7d53018 backups: encryptedFilenames and mountOptions are validated at provider level 2024-04-09 12:31:10 +02:00
Girish Ramakrishnan 424a3c2b53 validateEncryptionPassword need not by exported or async 2024-04-09 12:23:43 +02:00
Girish Ramakrishnan 6e629b984b typo in error message 2024-04-09 11:53:58 +02:00
Girish Ramakrishnan c73609211a import: fix typo in mountPoint parameter
mountpoint provider supports prefix (except not via UI). It's more
natural for the user to enter the actual mountpoint than the filesystem
path directly.
2024-04-08 19:21:59 +02:00
Johannes Zellner e5477351f8 dashboard: do not specify tooltip location in list view 2024-04-08 17:57:59 +02:00
Johannes Zellner d89f8d99a3 dashboard: only show relevant actions in list view 2024-04-08 16:44:15 +02:00
Johannes Zellner da472dff19 dashboard: use new image edit indicator in apps and applinks configuration 2024-04-08 16:28:38 +02:00
Johannes Zellner 2dc501dcbd dashboard: clear user selection when adding groups 2024-04-08 12:55:54 +02:00
Johannes Zellner 052b705c3c dashboard: preserve apps view type in localStorage 2024-04-06 16:16:48 +02:00
Johannes Zellner 24c8fca971 Better mobile list view and display app task progress 2024-04-06 16:12:08 +02:00
Johannes Zellner 86edabee4d Some visual improvements to the list view 2024-04-06 15:47:46 +02:00
Johannes Zellner d6f162a8ca dashboard: add initial version of app list view 2024-04-06 12:30:44 +02:00
Johannes Zellner 9e05a4eab7 Show background in all oidc pages 2024-04-06 10:52:25 +02:00
Johannes Zellner 32d9490856 Revert "dashboard: first use profile background if not exist try branding background"
This reverts commit 8db6da2de9.
2024-04-06 10:00:16 +02:00
Johannes Zellner 91d9f66eb8 dashboard: max-height is wrong here 2024-04-05 20:03:52 +02:00
Johannes Zellner 86986d8f34 Allow img-src blob: 2024-04-05 19:59:38 +02:00
Johannes Zellner 03ef9f109f dashboard: better image upload/edit indicator 2024-04-05 17:37:58 +02:00
Johannes Zellner 67a8228886 Show placeholder image for branding background 2024-04-05 17:15:31 +02:00
Johannes Zellner 8db6da2de9 dashboard: first use profile background if not exist try branding background 2024-04-05 17:11:37 +02:00
Johannes Zellner 544b8180b2 dashboard: add UI to change background image 2024-04-05 16:26:59 +02:00
Johannes Zellner 2515b032d0 Add branding background UI 2024-04-05 14:31:41 +02:00
Girish Ramakrishnan 6086b0e797 typo 2024-04-05 12:11:43 +02:00
Girish Ramakrishnan 2760e25c0f users: validate groupIds items 2024-04-05 11:59:16 +02:00
Girish Ramakrishnan 76aa0b4a70 add to changes 2024-04-04 18:25:35 +02:00
Girish Ramakrishnan 0e23687c7f cloudron-setup: lower memory requirement further for lightsail 2024-04-04 17:51:27 +02:00
Johannes Zellner 028b820d48 oidc: Reload the login view if session is gone 2024-04-04 17:32:58 +02:00
Johannes Zellner 2c81458954 Show branding background image in login view 2024-04-04 15:38:44 +02:00
Johannes Zellner ebe1883f8e Also trigger first time oidc auto login flow for initial admin creation 2024-04-04 11:42:57 +02:00
Girish Ramakrishnan 030e468829 docker: prune volumes on infra change 2024-04-04 11:36:26 +02:00
Johannes Zellner 68724bcb4f Revert "oidc: enable rpInitiated logout"
This reverts commit a6f4b2896a.
2024-04-04 10:41:00 +02:00
Johannes Zellner 6186bb54e4 Revert "oidc: allow post logout redirect back to the app"
This reverts commit 3ddf72a24d.
2024-04-04 10:40:53 +02:00
Johannes Zellner a4e822dec2 Make autologin token only one-time use 2024-04-04 10:29:36 +02:00
Johannes Zellner 5744cb7318 auto login from activation 2024-04-04 10:26:48 +02:00
Johannes Zellner 2f6a66dbd7 oidc: enable auto login when a token is provided 2024-04-03 18:11:21 +02:00
Johannes Zellner 91d3980e3b Add cloudron background branding apis 2024-04-03 17:27:22 +02:00
Johannes Zellner 3ddf72a24d oidc: allow post logout redirect back to the app 2024-04-03 15:49:03 +02:00
Johannes Zellner a6f4b2896a oidc: enable rpInitiated logout 2024-04-02 20:38:12 +02:00
Johannes Zellner c79ddbf948 dashboard: attempt to use firefox background color 2024-04-02 17:55:08 +02:00
Girish Ramakrishnan de99b8ecce Fix AVX support edge cases
* Always show restart button. When using a local VM, you can dynamically
switch flags. So, let the user rebuild. Show error if we cannot.
* The logs button is an "a" tag which is clickable despite ng-disabled
2024-04-01 23:05:20 +02:00
Girish Ramakrishnan 8b0bcde7ec cloudflare: result is now null and not empty array 2024-04-01 17:58:40 +02:00
Girish Ramakrishnan d862f1f5b4 cloudflare: fix crash when result is null 2024-04-01 17:31:20 +02:00
Girish Ramakrishnan 1c4f6315a6 mongodb: optional avx support in service routes 2024-04-01 17:31:20 +02:00
Girish Ramakrishnan 44eaac6685 cloudron-setup: add warning when installing with no avx 2024-04-01 17:31:20 +02:00
Johannes Zellner a89576965d Revert to lighter background 2024-04-01 12:42:44 +02:00
Girish Ramakrishnan 774f14327c addons: optional start mongodb based on AVX 2024-03-30 19:20:24 +01:00
Girish Ramakrishnan 6bd9391160 syslog: fix tests 2024-03-30 19:17:28 +01:00
Girish Ramakrishnan a82fb0c2cb typo from 110e68331 2024-03-30 19:17:28 +01:00
Girish Ramakrishnan 110e683318 rename checkManifestConstraints function 2024-03-30 18:25:37 +01:00
Girish Ramakrishnan 781ee77280 services: remove docker dynamic env hook 2024-03-30 18:25:37 +01:00
Johannes Zellner adc9894fde Use a darker gray background 2024-03-29 17:05:46 +01:00
Johannes Zellner c7bf5f2abc dashboard: sync list hover background with main background 2024-03-28 19:24:45 +01:00
Johannes Zellner 601e868afc dashboard: more font improvements 2024-03-28 17:22:17 +01:00
Johannes Zellner 25b1259c4c dashboard: use noto-sans instead of the dated Roboto 2024-03-28 17:03:53 +01:00
Johannes Zellner 1a8a111c79 dashboard: freshenup background, navbar and footer colors 2024-03-28 15:25:03 +01:00
Girish Ramakrishnan 497b3016c0 7.7.2 changes 2024-03-27 10:12:37 +01:00
Johannes Zellner fe9bd52b04 frontend: make uploads cancellable 2024-03-26 09:54:59 +01:00
Johannes Zellner 0705c77333 Frontend: update pankow for pretty fileupload size 2024-03-25 14:56:07 +01:00
Girish Ramakrishnan b66e77a2d8 Fix crash when system has no swap 2024-03-22 10:39:35 +01:00
Girish Ramakrishnan 4b4c8d8052 7.7.2 changes 2024-03-21 19:11:57 +01:00
Girish Ramakrishnan 4ee56782ba move syslog.js to top level 2024-03-21 19:09:51 +01:00
Girish Ramakrishnan 104997d77c syslog: change it to unix domain socket
docker is using a extra udp port for every container. when there is
a lot of containers, a lot of random udp ports get used up. this causes
problems when installing apps that require contiguous port ranges
2024-03-21 18:59:08 +01:00
Girish Ramakrishnan 8e07b3c96d remove unused variable 2024-03-21 17:11:17 +01:00
Johannes Zellner 4e618540f8 dashboard: preserve app link paths 2024-03-18 11:40:17 +01:00
Girish Ramakrishnan 49941a34b9 backups: deleted apps must also be displayed in contents 2024-03-14 16:14:50 +01:00
Johannes Zellner 771b797a23 frontend: update dependencies to fix filemanager empty folder content layout 2024-03-13 11:42:47 +01:00
Girish Ramakrishnan d09915bf6e scheduler: typo
(cherry picked from commit 09e00e6d58)
2024-03-12 18:06:24 +01:00
Johannes Zellner 264c94ff34 dashboard: remove bootstrap slider component 2024-03-12 17:33:38 +01:00
Johannes Zellner a90df99331 dashboard: migrate rsync concurrency settings to native range slider 2024-03-12 17:31:07 +01:00
Johannes Zellner 78f0d61627 dashboard: make backup upload part size steps explicit with native widget 2024-03-12 17:09:35 +01:00
Johannes Zellner 8c106b3435 dashboard: replace old slider with native widget 2024-03-12 16:44:08 +01:00
Johannes Zellner 42555c7231 dashboard: use native slider for mailbox storage quota 2024-03-12 15:43:08 +01:00
Johannes Zellner ab035a2afe dashboard: use native slider for mail size 2024-03-12 15:35:20 +01:00
Johannes Zellner 3a30eed3cd dashboard: remove commented slider 2024-03-12 15:29:30 +01:00
Johannes Zellner 4cb390374b dashboard: use native range slider for services 2024-03-12 15:28:47 +01:00
Girish Ramakrishnan 50179dd7eb 7.7.1 changes 2024-03-12 11:28:27 +01:00
Girish Ramakrishnan 2956c3360c postgresql: fix whitelist ext loading 2024-03-12 11:27:42 +01:00
Girish Ramakrishnan c634bdbd34 scheduler: do not create jobs of suspended apps
otherwise, when an app is uninstalling, it creates the docker containers
by calling getDynamicEnvironment. This ends up adding addonConfigs for the
docker addon and prevents the app from getting uninstalled.
2024-03-12 00:55:06 +01:00
Johannes Zellner 1892c0cd80 dashboard: use native slider element for app memory and cpu 2024-03-11 21:36:18 +01:00
Johannes Zellner 63b395982c dashboard: use less gulp processing for css turns out it actually made the files much larger 2024-03-11 19:03:42 +01:00
Johannes Zellner d50c8539b2 dashboard: update dependencies 2024-03-11 18:49:30 +01:00
232 changed files with 9301 additions and 14424 deletions
-25
View File
@@ -1,25 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 13
},
"rules": {
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-console": "off"
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"node": true,
"unused": true,
"esversion": 11
}
+74
View File
@@ -2755,4 +2755,78 @@
[7.7.1]
* postgresql: fix bug in loading of contrib extensions
* dashboard: use native slider element for app memory and cpu
[7.7.2]
* docker: use unix domain socket based logging instead of udp
* dashboard: use native slider element for app memory and cpu
* filemanager: fix empty folder content layout
* dashboard: preserve app link paths
* backups: deleted apps must also be displayed in contents
* filemanager: make uploads cancellable
* Fix crash on systemds with no swap
[8.0.0]
* mongodb: optionally start mongodb based on AVX support
* dashboard: font and color improvements
* docker: prune volumes on infra change
* oidc: initial login of admin and normal user now gets an OIDC session
* branding: default background image for the dashboard
* dashboard: list view for apps
* import: fix crash when using mountpoint provider
* dashboard: set '/' as keyboard shortcut
* app: memory limit is redefined to be just RAM and unlimited swap
* dashboard: rework filter UI
* cpu: rework cpu shares into cpu quota
* cifs: enable seal encryption by default
* updatechecker: fix bug where release info was not refreshed
* ovh: storage location domain has changed. add rbx region
* domains: add deSEC integration
* notfound: better message when navigating by IP address
* IPv6 only server installation
* Initial Ubuntu 24.04 (Noble Numbat) support
* syslog: handle potential multiline syslog input
* user directory: fixes to mandatory 2fa setting when cloudron connector is used
* notification: do not send login notification for external users
* dashboard: pending checklist indicator
* cloudron-support: add --recreate-docker and --recreate-container
* filemanager: add dark mode
* proxyauth: now uses oidc instead of ldap auth
* dashboard: add admin notes
* Use systemd-resolved as the system resolver. unbound is now only for mail server and recursive DNS lookups
[8.0.1]
* nfs: disable rpcbind service. we only support nfsv4 mounting
* dashboard: only show postinstall if notes are not just empty
* ami: disable route53
* mailer: add html version of test mail
* sshfs: server side copying
* backups: rewrite tgz backups using tar-stream
* backups: fix issue with s3 backend where files missing in remote was not detected correctly
* provision: redirect to correct task (setup/restore/activation)
[8.0.2]
* tgz: fix unhandled promise error handler
* tgz: add underflow/overflow proxy stream to ensure size of a changing file
* backups: give task a low oomScoreAdjust to not get killed
* Fix issue with uploads via File Manager where temp files were not cleaned up
* addons: fix crash when importing database of an app with no addons
* sshfs: if remote copy fails, fallback to sshfs based copy
* frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines
[8.0.3]
* logs: fix recursion when displaying box logs
* frontend: fix clear view in logs viewer
* dashboard: support links/markdown in checklist items
[8.0.4]
* ami: IMDv2 support
* ionos: add contract-owned eu-central-3
* dashboard: remove mailbox import/export feature
* backupcleaner: do not remove the backup in progress
* backups: make noop upload work again
* volumes: `/mnt/volumes` is reserved
* apps: do not log app logs to output
* sftp: restore mode and owner
* dashboard: also render checklist items in apps.html
+22 -25
View File
@@ -3,9 +3,7 @@
'use strict';
const argv = require('yargs').argv,
autoprefixer = require('gulp-autoprefixer'),
concat = require('gulp-concat'),
cssnano = require('gulp-cssnano'),
ejs = require('gulp-ejs'),
execSync = require('child_process').execSync,
fs = require('fs'),
@@ -48,6 +46,11 @@ gulp.task('fontawesome', function () {
.pipe(gulp.dest('dist/3rdparty/fontawesome/'));
});
gulp.task('noto-sans', function () {
return gulp.src('node_modules/@fontsource/noto-sans/**/*')
.pipe(gulp.dest('dist/3rdparty/noto-sans/'));
});
gulp.task('bootstrap', function () {
return gulp.src('node_modules/bootstrap-sass/assets/javascripts/bootstrap.min.js')
.pipe(gulp.dest('dist/3rdparty/js'));
@@ -72,7 +75,7 @@ gulp.task('3rdparty-copy', function () {
]).pipe(gulp.dest('dist/3rdparty/'));
});
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'moment', 'bootstrap', 'fontawesome']));
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'moment', 'bootstrap', 'fontawesome', 'noto-sans']));
// --------------
// JavaScript
@@ -110,6 +113,15 @@ gulp.task('js-setupaccount', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-activation', function () {
return gulp.src(['src/js/activation.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('activation.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-setup', function () {
return gulp.src(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
@@ -119,15 +131,6 @@ gulp.task('js-setup', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-setupdns', function () {
return gulp.src(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('setupdns.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-restore', function () {
return gulp.src(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
@@ -137,7 +140,7 @@ gulp.task('js-restore', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js', gulp.series([ 'js-index', 'js-passwordreset', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
gulp.task('js', gulp.series([ 'js-index', 'js-passwordreset', 'js-setupaccount', 'js-activation', 'js-setup', 'js-restore' ]));
// --------------
// HTML
@@ -147,15 +150,11 @@ gulp.task('html-views', function () {
return gulp.src('src/views/**/*.html').pipe(gulp.dest('dist/views'));
});
gulp.task('html-templates', function () {
return gulp.src('src/templates/**/*').pipe(gulp.dest('dist/templates'));
});
gulp.task('html-raw', function () {
return gulp.src('src/*.html').pipe(ejs({ apiOrigin: apiOrigin, revision: revision }, {}, { ext: '.html' })).pipe(gulp.dest('dist'));
});
gulp.task('html', gulp.series(['html-views', 'html-templates', 'html-raw']));
gulp.task('html', gulp.series(['html-views', 'html-raw']));
// --------------
// CSS
@@ -163,11 +162,10 @@ gulp.task('html', gulp.series(['html-views', 'html-templates', 'html-raw']));
gulp.task('css', function () {
return gulp.src('src/*.scss')
.pipe(sourcemaps.init())
.pipe(sass({ includePaths: ['node_modules/bootstrap-sass/assets/stylesheets/'] }).on('error', sass.logError))
.pipe(autoprefixer())
.pipe(cssnano())
.pipe(sourcemaps.write())
.pipe(sass({ includePaths: [
'node_modules/bootstrap-sass/assets/stylesheets/',
'node_modules/@fontsource/'
]}).on('error', sass.logError))
.pipe(gulp.dest('dist'));
});
@@ -202,10 +200,9 @@ gulp.task('watch', function (done) {
gulp.watch(['src/translation/*'], gulp.series(['translation']));
gulp.watch(['src/**/*.html'], gulp.series(['html']));
gulp.watch(['src/views/*.html'], gulp.series(['html-views']));
gulp.watch(['src/templates/*.html'], gulp.series(['html-templates']));
gulp.watch(['scripts/createTimezones.js', 'src/js/utils.js'], gulp.series(['timezones']));
gulp.watch(['src/js/activation.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-activation']));
gulp.watch(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setup']));
gulp.watch(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setupdns']));
gulp.watch(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-restore']));
gulp.watch(['src/js/passwordreset.js', 'src/js/utils.js'], gulp.series(['js-passwordreset']));
gulp.watch(['src/js/setupaccount.js', 'src/js/utils.js'], gulp.series(['js-setupaccount']));
+40 -3425
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -13,19 +13,18 @@
"author": "",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
"@fontsource/noto-sans": "^5.0.21",
"@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap-sass": "^3.4.3",
"chart.js": "^4.3.0",
"chart.js": "^4.4.2",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-cssnano": "^2.1.3",
"gulp-ejs": "^5.1.0",
"gulp-sass": "^5.1.0",
"gulp-serve": "^1.4.0",
"gulp-sourcemaps": "^3.0.0",
"moment": "^2.29.4",
"sass": "^1.63.3",
"moment": "^2.30.1",
"sass": "^1.75.0",
"yargs": "^17.7.2"
},
"eslintConfig": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,255 +0,0 @@
/*! =======================================================
VERSION 6.0.12
========================================================= */
/*! =========================================================
* bootstrap-slider.js
*
* Maintainers:
* Kyle Kemp
* - Twitter: @seiyria
* - Github: seiyria
* Rohit Kalkur
* - Twitter: @Rovolutionary
* - Github: rovolution
*
* =========================================================
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================= */
.slider {
display: inline-block;
vertical-align: middle;
position: relative;
}
.slider.slider-horizontal {
width: 210px;
height: 20px;
}
.slider.slider-horizontal .slider-track {
height: 10px;
width: 100%;
margin-top: -5px;
top: 50%;
left: 0;
}
.slider.slider-horizontal .slider-selection,
.slider.slider-horizontal .slider-track-low,
.slider.slider-horizontal .slider-track-high {
height: 100%;
top: 0;
bottom: 0;
}
.slider.slider-horizontal .slider-tick,
.slider.slider-horizontal .slider-handle {
margin-left: -10px;
margin-top: -5px;
}
.slider.slider-horizontal .slider-tick.triangle,
.slider.slider-horizontal .slider-handle.triangle {
border-width: 0 10px 10px 10px;
width: 0;
height: 0;
border-bottom-color: #0480be;
margin-top: 0;
}
.slider.slider-horizontal .slider-tick-label-container {
white-space: nowrap;
margin-top: 20px;
}
.slider.slider-horizontal .slider-tick-label-container .slider-tick-label {
padding-top: 4px;
display: inline-block;
text-align: center;
}
.slider.slider-vertical {
height: 210px;
width: 20px;
}
.slider.slider-vertical .slider-track {
width: 10px;
height: 100%;
margin-left: -5px;
left: 50%;
top: 0;
}
.slider.slider-vertical .slider-selection {
width: 100%;
left: 0;
top: 0;
bottom: 0;
}
.slider.slider-vertical .slider-track-low,
.slider.slider-vertical .slider-track-high {
width: 100%;
left: 0;
right: 0;
}
.slider.slider-vertical .slider-tick,
.slider.slider-vertical .slider-handle {
margin-left: -5px;
margin-top: -10px;
}
.slider.slider-vertical .slider-tick.triangle,
.slider.slider-vertical .slider-handle.triangle {
border-width: 10px 0 10px 10px;
width: 1px;
height: 1px;
border-left-color: #0480be;
margin-left: 0;
}
.slider.slider-vertical .slider-tick-label-container {
white-space: nowrap;
}
.slider.slider-vertical .slider-tick-label-container .slider-tick-label {
padding-left: 4px;
}
.slider.slider-disabled .slider-handle {
background-image: -webkit-linear-gradient(top, #dfdfdf 0%, #bebebe 100%);
background-image: -o-linear-gradient(top, #dfdfdf 0%, #bebebe 100%);
background-image: linear-gradient(to bottom, #dfdfdf 0%, #bebebe 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdfdfdf', endColorstr='#ffbebebe', GradientType=0);
}
.slider.slider-disabled .slider-track {
background-image: -webkit-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%);
background-image: -o-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%);
background-image: linear-gradient(to bottom, #e5e5e5 0%, #e9e9e9 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe5e5e5', endColorstr='#ffe9e9e9', GradientType=0);
cursor: not-allowed;
}
.slider input {
display: none;
}
.slider .tooltip.top {
margin-top: -36px;
}
.slider .tooltip-inner {
white-space: nowrap;
}
.slider .hide {
display: none;
}
.slider-track {
position: absolute;
cursor: pointer;
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%);
background-image: -o-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%);
background-image: linear-gradient(to bottom, #f5f5f5 0%, #f9f9f9 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff9f9f9', GradientType=0);
-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.slider-selection {
position: absolute;
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0);
-webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-radius: 4px;
}
.slider-selection.tick-slider-selection {
background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0);
}
.slider-track-low,
.slider-track-high {
position: absolute;
background: transparent;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-radius: 4px;
}
.slider-handle {
position: absolute;
width: 20px;
height: 20px;
background-color: #337ab7;
background-image: -webkit-linear-gradient(top, #149bdf 0%, #0480be 100%);
background-image: -o-linear-gradient(top, #149bdf 0%, #0480be 100%);
background-image: linear-gradient(to bottom, #149bdf 0%, #0480be 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf', endColorstr='#ff0480be', GradientType=0);
filter: none;
-webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05);
box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05);
border: 0px solid transparent;
}
.slider-handle.round {
border-radius: 50%;
}
.slider-handle.triangle {
background: transparent none;
}
.slider-handle.custom {
background: transparent none;
}
.slider-handle.custom::before {
line-height: 20px;
font-size: 20px;
content: '\2605';
color: #726204;
}
.slider-tick {
position: absolute;
width: 20px;
height: 20px;
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0);
-webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
filter: none;
opacity: 0.8;
border: 0px solid transparent;
}
.slider-tick.round {
border-radius: 50%;
}
.slider-tick.triangle {
background: transparent none;
}
.slider-tick.custom {
background: transparent none;
}
.slider-tick.custom::before {
line-height: 20px;
font-size: 20px;
content: '\2605';
color: #726204;
}
.slider-tick.in-selection {
background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0);
opacity: 1;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-232
View File
@@ -1,232 +0,0 @@
(function(factory) {
if (typeof define === 'function' && define.amd) {
define(['angular', 'bootstrap-slider'], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('angular'), require('bootstrap-slider'));
} else if (window) {
factory(window.angular, window.Slider);
}
})(function (angular, Slider) {
angular.module('ui.bootstrap-slider', [])
.directive('slider', ['$parse', '$timeout', '$rootScope', function ($parse, $timeout, $rootScope) {
return {
restrict: 'AE',
replace: true,
template: '<div><input class="slider-input" type="text" style="width:100%" /></div>',
require: 'ngModel',
scope: {
max: "=",
min: "=",
step: "=",
value: "=",
ngModel: '=',
ngDisabled: '=',
range: '=',
sliderid: '=',
ticks: '=',
ticksLabels: '=',
ticksSnapBounds: '=',
ticksPositions: '=',
scale: '=',
focus: '=',
formatter: '&',
onStartSlide: '&',
onStopSlide: '&',
onSlide: '&'
},
link: function ($scope, element, attrs, ngModelCtrl, $compile) {
var ngModelDeregisterFn, ngDisabledDeregisterFn;
var slider = initSlider();
function initSlider() {
var options = {};
function setOption(key, value, defaultValue) {
options[key] = value || defaultValue;
}
function setFloatOption(key, value, defaultValue) {
options[key] = value || value === 0 ? parseFloat(value) : defaultValue;
}
function setBooleanOption(key, value, defaultValue) {
options[key] = value ? value + '' === 'true' : defaultValue;
}
function getArrayOrValue(value) {
return (angular.isString(value) && value.indexOf("[") === 0) ? angular.fromJson(value) : value;
}
setOption('id', $scope.sliderid);
setOption('orientation', attrs.orientation, 'horizontal');
setOption('selection', attrs.selection, 'before');
setOption('handle', attrs.handle, 'round');
setOption('tooltip', attrs.sliderTooltip || attrs.tooltip, 'show');
setOption('tooltip_position', attrs.sliderTooltipPosition, 'top');
setOption('tooltipseparator', attrs.tooltipseparator, ':');
setOption('ticks', $scope.ticks);
setOption('ticks_labels', $scope.ticksLabels);
setOption('ticks_snap_bounds', $scope.ticksSnapBounds);
setOption('ticks_positions', $scope.ticksPositions);
setOption('scale', $scope.scale, 'linear');
setOption('focus', $scope.focus);
setFloatOption('min', $scope.min, 0);
setFloatOption('max', $scope.max, 10);
setFloatOption('step', $scope.step, 1);
var strNbr = options.step + '';
var dotPos = strNbr.search(/[^.,]*$/);
var decimals = strNbr.substring(dotPos);
setFloatOption('precision', attrs.precision, decimals.length);
setBooleanOption('tooltip_split', attrs.tooltipsplit, false);
setBooleanOption('enabled', attrs.enabled, true);
setBooleanOption('naturalarrowkeys', attrs.naturalarrowkeys, false);
setBooleanOption('reversed', attrs.reversed, false);
setBooleanOption('range', $scope.range, false);
if (options.range) {
if (angular.isArray($scope.value)) {
options.value = $scope.value;
}
else if (angular.isString($scope.value)) {
options.value = getArrayOrValue($scope.value);
if (!angular.isArray(options.value)) {
var value = parseFloat($scope.value);
if (isNaN(value)) value = 5;
if (value < $scope.min) {
value = $scope.min;
options.value = [value, options.max];
}
else if (value > $scope.max) {
value = $scope.max;
options.value = [options.min, value];
}
else {
options.value = [options.min, options.max];
}
}
}
else {
options.value = [options.min, options.max]; // This is needed, because of value defined at $.fn.slider.defaults - default value 5 prevents creating range slider
}
$scope.ngModel = options.value; // needed, otherwise turns value into [null, ##]
}
else {
setFloatOption('value', $scope.value, 5);
}
if (attrs.formatter) {
options.formatter = function(value) {
return $scope.formatter({value: value});
}
}
// check if slider jQuery plugin exists
if ('$' in window && $.fn.slider) {
// adding methods to jQuery slider plugin prototype
$.fn.slider.constructor.prototype.disable = function () {
this.picker.off();
};
$.fn.slider.constructor.prototype.enable = function () {
this.picker.on();
};
}
// destroy previous slider to reset all options
if (element[0].__slider)
element[0].__slider.destroy();
var slider = new Slider(element[0].getElementsByClassName('slider-input')[0], options);
element[0].__slider = slider;
// everything that needs slider element
var updateEvent = getArrayOrValue(attrs.updateevent);
if (angular.isString(updateEvent)) {
// if only single event name in string
updateEvent = [updateEvent];
}
else {
// default to slide event
updateEvent = ['slide'];
}
angular.forEach(updateEvent, function (sliderEvent) {
slider.on(sliderEvent, function (ev) {
ngModelCtrl.$setViewValue(ev);
});
});
slider.on('change', function (ev) {
ngModelCtrl.$setViewValue(ev.newValue);
});
// Event listeners
var sliderEvents = {
slideStart: 'onStartSlide',
slide: 'onSlide',
slideStop: 'onStopSlide'
};
angular.forEach(sliderEvents, function (sliderEventAttr, sliderEvent) {
var fn = $parse(attrs[sliderEventAttr]);
slider.on(sliderEvent, function (ev) {
if ($scope[sliderEventAttr]) {
$scope.$apply(function () {
fn($scope.$parent, { $event: ev, value: ev });
});
}
});
});
// deregister ngDisabled watcher to prevent memory leaks
if (angular.isFunction(ngDisabledDeregisterFn)) {
ngDisabledDeregisterFn();
ngDisabledDeregisterFn = null;
}
ngDisabledDeregisterFn = $scope.$watch('ngDisabled', function (value) {
if (value) {
slider.disable();
}
else {
slider.enable();
}
});
// deregister ngModel watcher to prevent memory leaks
if (angular.isFunction(ngModelDeregisterFn)) ngModelDeregisterFn();
ngModelDeregisterFn = $scope.$watch('ngModel', function (value) {
if($scope.range){
slider.setValue(value);
}else{
slider.setValue(parseFloat(value));
}
slider.relayout();
}, true);
return slider;
}
var watchers = ['min', 'max', 'step', 'range', 'scale', 'ticksLabels', 'ticks'];
angular.forEach(watchers, function (prop) {
$scope.$watch(prop, function () {
slider = initSlider();
});
});
var globalEvents = ['relayout', 'refresh', 'resize'];
angular.forEach(globalEvents, function(event) {
if(angular.isFunction(slider[event])) {
$scope.$on('slider:' + event, function () {
slider[event]();
});
}
});
}
};
}])
;
});
-50
View File
@@ -1,50 +0,0 @@
// !!!
// This module is manually patched by us to not only report valid domains, but verify that subdomains are not accepted
// !!!
'use strict';
angular.module('ngTld', [])
.factory('ngTld', ngTld)
.directive('checkTld', checkTld);
function ngTld() {
function isValid(path) {
// https://github.com/oncletom/tld.js/issues/58
return (path.slice(-1) !== '.') && tld.isValid(path);
}
function tldExists(path) {
return (path.slice(-1) !== '.') && path === tld.getDomain(path);
}
function isSubdomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path !== tld.getDomain(path);
}
function isNakedDomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path === tld.getDomain(path);
}
return {
isValid: isValid,
tldExists: tldExists,
isSubdomain: isSubdomain,
isNakedDomain: isNakedDomain
};
}
function checkTld(ngTld) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
ngModel.$validators.invalidTld = function(modelValue, viewValue) {
return ngTld.tldExists(ngModel.$viewValue.toLowerCase());
};
ngModel.$validators.invalidSubdomain = function(modelValue, viewValue) {
return !ngTld.isSubdomain(ngModel.$viewValue.toLowerCase());
};
}
};
}
-1913
View File
File diff suppressed because one or more lines are too long
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Setup</title>
<meta name="description" content="Cloudron Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/activation.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container" ng-show="initialized">
<div class="row" ng-show="view === 'owner'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form role="form" name="ownerForm" ng-submit="owner.submit()" novalidate>
<div class="row">
<div class="col-md-12 text-center">
<h1>Welcome to Cloudron</h1>
<h3>Set up Admin Account</h3>
<p class="has-error text-center" ng-show="owner.error.generic">{{ owner.error.generic }}</p>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': ownerForm.displayName.$dirty && ownerForm.displayName.$invalid }">
<label class="control-label" for="inputDisplayName">Full Name</label>
<input type="text" class="form-control" ng-model="owner.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" ng-disabled="owner.busy" autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.email.$dirty && ownerForm.email.$invalid) || (!ownerForm.email.$dirty && owner.error.email) }">
<label class="control-label" for="inputEmail">Email <sup><a ng-href="https://docs.cloudron.io/installation/#admin-account" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="email" class="form-control" ng-model="owner.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" ng-disabled="owner.busy">
<small>A valid email is required for Let's Encrypt certificates. This email is local to your Cloudron. </small>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.username.$dirty && ownerForm.username.$invalid) || (!ownerForm.username.$dirty && owner.error.username) }">
<label class="control-label" for="inputUsername">Username</label>
<input type="text" class="form-control" ng-model="owner.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off" ng-disabled="owner.busy">
<small>{{ owner.error.username }}</small>
</div>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': ownerForm.password.$dirty && ownerForm.password.$invalid }">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" ng-model="owner.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off" ng-disabled="owner.busy" password-reveal>
<small><span ng-show="ownerForm.password.$dirty && ownerForm.password.$invalid">Password must be at least 8 characters</span> &nbsp;</small>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-success" ng-disabled="ownerForm.$invalid || owner.busy"><i class="fa fa-circle-notch fa-spin" ng-show="owner.busy"></i> Create Admin</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="row" ng-show="view === 'finished'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px 40px;">
<div class="row">
<div class="col-md-12 text-center">
<h1>Cloudron is ready to use</h1>
</div>
</div>
<p>
&nbsp; &nbsp; Before you start:
<ul class="fa-ul">
<li><i class="fa-li fa fa-users"></i>
<b>User management</b>: Cloudron has a central user directory. When installing an app,
you can set it up to authenticate against this directory.
</li>
<br/>
<li><i class="fa-li fa fa-envelope-open"></i>
<b>Email Configuration</b>: Apps are configured to send email based on the settings in the Email view.
This saves you the trouble of having to configure mail settings inside each app.
</li>
<br/>
<li><i class="fa-li fa fa-archive"></i>
<b>Backups</b>: Store your backups on storage services completely independent from your server.
You can use backups to seamlessly migrate your setup to another server.
</li>
<br/>
<li><i class="fa-li fa fa-birthday-cake"></i>
<b>Updates</b>: The Cloudron team tracks upstream releases and publishes app updates after testing.
Your apps are kept fresh &amp; secure.
</li>
</ul>
</p>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a class="btn btn-success" ng-href="firstTimeLoginUrl">Proceed to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+1 -1
View File
@@ -23,7 +23,7 @@
height: 100%;
width: 100%;
text-align: center;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-family: "Noto Sans", Helvetica, Arial, sans-serif;
line-height: 1.846;
}
+6 -1
View File
@@ -6,6 +6,11 @@ tmp.forEach(function (pair) {
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
});
window.location.href = '/';
var redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
window.location.href = redirectTo;
</script>
+1 -10
View File
@@ -15,11 +15,10 @@
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/slick.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/bootstrap-slider/bootstrap-slider.min.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -50,10 +49,6 @@
<script type="text/javascript" src="/3rdparty/js/angular-fittext.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js?<%= revision %>"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-tld.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></script>
@@ -73,10 +68,6 @@
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Bootstrap slider -->
<script type="text/javascript" src="/3rdparty/bootstrap-slider/bootstrap-slider.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/bootstrap-slider/slider.js?<%= revision %>"></script>
<!-- Anugular Multiselect https://github.com/sebastianha/angular-bootstrap-multiselect -->
<script type="text/javascript" src="/3rdparty/js/angular-bootstrap-multiselect.js?<%= revision %>"></script>
+100
View File
@@ -0,0 +1,100 @@
'use strict';
/* global angular, window, document, localStorage, redirectIfNeeded */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.setupToken = '';
$scope.firstTimeLoginUrl = '';
$scope.owner = {
error: null,
busy: false,
email: '',
displayName: '',
username: '',
password: '',
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
Client.createAdmin(data, function (error, autoLoginToken) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = autoLoginToken;
$scope.firstTimeLoginUrl = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
setView('finished');
});
}
};
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
} else {
$scope.view = 'owner';
}
}
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'activation')) return; // redirected to some other view...
setView(search.view);
$scope.setupToken = search.setupToken;
$scope.initialized = true;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
});
}
init();
}]);
+160 -67
View File
@@ -218,13 +218,14 @@ const REGIONS_LINODE = [
// note: ovh also has a storage endpoint but that only supports path style access (https://docs.ovh.com/au/en/storage/object-storage/s3/location/)
const REGIONS_OVH = [
{ name: 'Beauharnois (BHS)', value: 'https://s3.bhs.cloud.ovh.net', region: 'bhs' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3.de.cloud.ovh.net', region: 'de' },
{ name: 'Gravelines (GRA)', value: 'https://s3.gra.cloud.ovh.net', region: 'gra' },
{ name: 'Strasbourg (SBG)', value: 'https://s3.sbg.cloud.ovh.net', region: 'sbg' },
{ name: 'London (UK)', value: 'https://s3.uk.cloud.ovh.net', region: 'uk' },
{ name: 'Sydney (SYD)', value: 'https://s3.syd.cloud.ovh.net', region: 'syd' },
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' },
{ name: 'Beauharnois (BHS)', value: 'https://s3.bhs.io.cloud.ovh.net', region: 'bhs' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3.de.io.cloud.ovh.net', region: 'de' },
{ name: 'Gravelines (GRA)', value: 'https://s3.gra.io.cloud.ovh.net', region: 'gra' },
{ name: 'Roubaix (RBX)', value: 'https://s3.rbx.io.cloud.ovh.net', region: 'rbx' },
{ name: 'Strasbourg (SBG)', value: 'https://s3.sbg.io.cloud.ovh.net', region: 'sbg' },
{ name: 'London (UK)', value: 'https://s3.uk.io.cloud.ovh.net', region: 'uk' },
{ name: 'Sydney (SYD)', value: 'https://s3.syd.io.cloud.ovh.net', region: 'syd' },
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.io.cloud.ovh.net', region: 'waw' },
];
const ENDPOINTS_OVH = [
@@ -239,9 +240,10 @@ const ENDPOINTS_OVH = [
// https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints
const REGIONS_IONOS = [
{ name: 'Frankfurt (DE)', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' }, // default
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' }, // default
{ name: 'Berlin (eu-central-3)', value: 'https://s3.eu-central-3.ionoscloud.com', region: 'de' }, // default. contract-owned
{ name: 'Frankfurt (DE)', value: 'https://s3.eu-central-1.ionoscloud.com', region: 'de' },
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' },
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' },
];
// this is not used anywhere because upcloud needs endpoint URL. we detect region from the URL (https://upcloud.com/data-centres)
@@ -327,7 +329,7 @@ function prettyBinarySize(size, fallback) {
// we can also use KB here (JEDEC)
var i = Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'KiB', 'MiB', 'GiB', 'TiB'][i];
return (size / Math.pow(1024, i)).toFixed(3) * 1 + ' ' + ['B', 'KiB', 'MiB', 'GiB', 'TiB'][i];
}
// decimal units (SI) 1000 based
@@ -365,6 +367,8 @@ angular.module('Application').filter('trKeyFromPeriod', function () {
angular.module('Application').filter('prettyDate', function ($translate) {
// http://ejohn.org/files/pretty.js
return function prettyDate(utc) {
if (utc === null) return $translate.instant('main.prettyDate.never', {});
var date = new Date(utc), // this converts utc into browser timezone and not cloudron timezone!
diff = (((new Date()).getTime() - date.getTime()) / 1000) + 30, // add 30seconds for clock skew
day_diff = Math.floor(diff / 86400);
@@ -448,6 +452,73 @@ function translateFilterFactory($parse, $translate) {
translateFilterFactory.displayName = 'translateFilterFactory';
angular.module('Application').filter('tr', translateFilterFactory);
// checks provision status and redirects to correct view
// {
// setup: { active, message, errorMessage }
// restore { active, message, errorMessage }
// activated
// adminFqn
// }
// returns true if redirected . currentView is one of dashboard/restore/setup/activation
function redirectIfNeeded(status, currentView) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return false;
}
if (status.activated) {
console.log('Already activated');
if (currentView === 'dashboard') {
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setup.html' + window.location.search;
return true;
}
return false;
}
window.location.href = 'https://' + status.adminFqdn + '/';
return true;
}
if (status.setup.active) {
console.log('Setup is active');
if (currentView === 'setup') return false;
window.location.href = '/setup.html' + window.location.search;
return true;
}
if (status.restore.active) {
console.log('Restore is active');
if (currentView === 'restore') return;
window.location.href = '/restore.html' + window.location.search;
return true;
}
if (status.adminFqdn) {
console.log('adminFqdn is set');
// if we are here from https://ip/activation.html ,go to https://admin/activation.html
if (status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
return true;
}
if (currentView === 'activation') return false;
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
return true;
}
if (currentView === 'dashboard') {
window.location.href = '/setup.html' + window.location.search;
return true;
}
// if we are here, proceed with current view
return false;
}
// ----------------------------------------------
// Cloudron REST API wrapper
@@ -637,6 +708,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
// window.location fallback for websocket connections which do not have relative uris
this.apiOrigin = '<%= apiOrigin %>' || window.location.origin;
this.avatar = '';
this.background = '';
this._availableLanguages = ['en'];
this._appstoreAppCache = [];
@@ -825,6 +897,31 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.hasCloudronBackground = function (callback) {
get('/api/v1/branding/cloudron_background', null, function (error, data, status) {
if (error && error.statusCode !== 404) callback(error);
else if (error) callback(null, false);
else callback(null, status === 200);
});
};
Client.prototype.changeCloudronBackground = function (background, callback) {
var fd = new FormData();
if (background) fd.append('background', background);
var config = {
headers: { 'Content-Type': undefined },
transformRequest: angular.identity
};
post('/api/v1/branding/cloudron_background', fd, config, function (error, data, status) {
if (error) return callback(error);
if (status !== 202) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.changeCloudronAvatar = function (avatarFile, callback) {
var fd = new FormData();
fd.append('avatar', avatarFile);
@@ -859,7 +956,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
portBindings: config.portBindings,
ports: config.ports,
accessRestriction: config.accessRestriction,
cert: config.cert,
key: config.key,
@@ -881,7 +978,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
portBindings: config.portBindings,
ports: config.ports,
backupId: config.backupId,
overwriteDns: !!config.overwriteDns
};
@@ -954,6 +1051,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.ackAppChecklistItem = function (appId, key, acknowledged, callback) {
put('/api/v1/apps/' + appId + '/checklist/' + key, { done: acknowledged }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 202) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.updateApp = function (id, manifest, options, callback) {
var data = {
appStoreId: manifest.id + '@' + manifest.version,
@@ -1039,7 +1145,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
const storageConfig = Object.assign({}, backupConfig);
delete storageConfig.limits;
post('/api/v1/backups/config/storage', backupConfig, null, function (error, data, status) {
post('/api/v1/backups/config/storage', storageConfig, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -1485,16 +1591,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.restore = function (backupConfig, remotePath, version, ipv4Config, skipDnsSetup, setupToken, callback) {
var data = {
backupConfig: backupConfig,
remotePath: remotePath,
version: version,
ipv4Config: ipv4Config,
skipDnsSetup: skipDnsSetup,
setupToken: setupToken
};
Client.prototype.restore = function (data, callback) {
post('/api/v1/provision/restore', data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status));
@@ -1830,15 +1927,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.getAppLimits = function (appId, callback) {
get('/api/v1/apps/' + appId + '/limits', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.limits);
});
};
Client.prototype.getAppWithTask = function (appId, callback) {
var that = this;
@@ -1888,6 +1976,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.detectIp = function (callback) {
post('/api/v1/provision/detect_ip', {}, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.setup = function (data, callback) {
post('/api/v1/provision/setup', data, null, function (error, data, status) {
if (error) return callback(error);
@@ -1913,10 +2010,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (error) return callback(error);
if (status !== 201) return callback(new ClientError(status, result));
that.setToken(result.token);
that.setUserInfo({ username: data.username, email: data.email, admin: true, twoFactorAuthenticationEnabled: false, source: '', avatarUrl: null });
callback(null, result.activated);
callback(null, result.token);
});
};
@@ -2082,15 +2176,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.disks = function (callback) {
get('/api/v1/system/disks', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.diskUsage = function (callback) {
get('/api/v1/system/disk_usage', null, function (error, data, status) {
if (error) return callback(error);
@@ -2216,7 +2301,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
// amend properties to mimick full app
data.applinks.forEach(function (applink) {
applink.type = APP_TYPES.LINK;
applink.fqdn = new URL(applink.upstreamUri).hostname;
applink.fqdn = applink.upstreamUri;
applink.manifest = { addons: {}};
applink.installationState = ISTATES.INSTALLED;
applink.runState = RSTATES.RUNNING;
@@ -2727,6 +2812,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.login = function () {
this.setToken(null);
localStorage.setItem('redirectToHash', window.location.hash);
// start oidc flow
window.location.href = this.apiOrigin + '/openid/auth?client_id=' + ('<%= apiOrigin %>' ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
};
@@ -3060,7 +3147,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.setSpamAcl = function (acl, callback) {
post('/api/v1/mailserver/spam_acl', { whitelist: acl.whitelist, blacklist: acl.blacklist }, null, function (error, data, status) {
post('/api/v1/mailserver/spam_acl', { allowlist: acl.allowlist, blocklist: acl.blocklist }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -3438,8 +3525,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
mountOptions: mountOptions
};
console.log('---update', data)
post('/api/v1/volumes/' + volumeId, data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
@@ -3627,6 +3712,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
var ACTION_USER_UPDATE = 'user.update';
var ACTION_USER_TRANSFER = 'user.transfer';
var ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE = 'userdirectory.profileconfig.update';
var ACTION_MAIL_LOCATION = 'mail.location';
var ACTION_MAIL_ENABLED = 'mail.enabled';
var ACTION_MAIL_DISABLED = 'mail.disabled';
@@ -3658,6 +3745,11 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
return pre + (app.label || app.fqdn || app.subdomain) + ' (' + app.manifest.title + ') ';
}
function eventBy() {
if (eventLog.source && eventLog.source.username) return ' by ' + eventLog.source.username;
return '';
}
switch (eventLog.action) {
case ACTION_ACTIVATE:
return 'Cloudron was activated';
@@ -3672,24 +3764,22 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (!data.app) return '';
app = data.app;
var q = function (x) {
return '"' + x + '"';
};
if ('accessRestriction' in data) { // since it can be null
return 'Access restriction ' + appName('of', app) + ' was changed';
} else if ('operators' in data) {
return 'Operators ' + appName('of', app) + ' was changed';
} else if (data.label) {
return 'Label ' + appName('of', app) + ' was set to ' + q(data.label);
return `Label ${appName('of', app)} was set to ${data.label}`;
} else if (data.tags) {
return 'Tags ' + appName('of', app) + ' was set to ' + q(data.tags.join(','));
return `Tags ${appName('of', app)} was set to ${data.tags.join(', ')}`;
} else if (data.icon) {
return 'Icon ' + appName('of', app) + ' was changed';
} else if (data.memoryLimit) {
return 'Memory limit ' + appName('of', app) + ' was set to ' + data.memoryLimit;
} else if (data.cpuShares) {
return 'Memory limit ' + appName('of', app) + ' was set to ' + prettyBinarySize(data.memoryLimit);
} else if (data.cpuShares) { // replaced by cpuQuota in 8.0
return 'CPU shares ' + appName('of', app) + ' was set to ' + Math.round((data.cpuShares * 100)/1024) + '%';
} else if (data.cpuQuota) {
return 'CPU quota ' + appName('of', app) + ' was set to ' + data.cpuQuota + '%';
} else if (data.env) {
return 'Env vars ' + appName('of', app) + ' was changed';
} else if ('debugMode' in data) { // since it can be null
@@ -3699,9 +3789,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
return appName('', app, 'App') + ' was taken out of repair mode';
}
} else if ('enableBackup' in data) {
return 'Automatic backups ' + appName('of', app) + ' were ' + (data.enableBackup ? 'enabled' : 'disabled');
return 'Automatic backups ' + appName('of', app) + ' was ' + (data.enableBackup ? 'enabled' : 'disabled');
} else if ('enableAutomaticUpdate' in data) {
return 'Automatic updates ' + appName('of', app) + ' were ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
return 'Automatic updates ' + appName('of', app) + ' was ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
} else if ('reverseProxyConfig' in data) {
return 'Reverse proxy configuration ' + appName('of', app) + ' was updated';
} else if ('upstreamUri' in data) {
@@ -3736,11 +3826,11 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
} else {
return 'Icon ' + appName('of', app) + ' was reset';
}
} else if (('mailboxName' in data) && data.mailboxName !== data.app.mailboxName) {
} else if ('mailboxName' in data) {
if (data.mailboxName) {
return 'Mailbox ' + appName('of', app) + ' was set to ' + q(data.mailboxName);
return `Mailbox ${appName('of', app)} was set to ${data.mailboxDisplayName || '' } ${data.mailboxName}@${data.mailboxDomain}`;
} else {
return 'Mailbox ' + appName('of', app) + ' was reset';
return 'Mailbox ' + appName('of', app) + ' was disabled';
}
}
@@ -3749,7 +3839,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_APP_INSTALL:
if (!data.app) return '';
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app);
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app) + eventBy();
case ACTION_APP_RESTORE:
if (!data.app) return '';
@@ -3955,9 +4045,11 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_USER_LOGIN:
if (data.mailboxId) {
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to mailbox ' + data.mailboxId;
} else {
} else if (data.appId) {
app = this.getCachedAppSync(data.appId);
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in 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:
@@ -3966,6 +4058,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_USER_LOGOUT:
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged out';
case ACTION_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. ';
if (data.fromIpv4 !== data.toIpv4) details += 'From IPv4 ' + data.fromIpv4 + ' to ' + data.toIpv4 + '. ';
@@ -4001,8 +4096,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (source.appId) {
var app = this.getCachedAppSync(source.appId);
line += ' - ' + (app ? app.fqdn : source.appId);
} else if (source.ip) {
line += ' - ' + source.ip;
}
return line;
+38 -28
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false */
/* global angular:false, window, document, localStorage, redirectIfNeeded */
/* global $:false */
/* global async */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES,NOTIFICATION_TYPES */
@@ -19,7 +19,7 @@ if (search.accessToken) {
}
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.bootstrap-slider', 'ngTld', 'ui.multiselect']);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.multiselect']);
app.config(['NotificationProvider', function (NotificationProvider) {
NotificationProvider.setOptions({
@@ -305,6 +305,36 @@ app.filter('installationActive', function () {
};
});
// color indicator in app list
app.filter('installationStateClass', function () {
const ERROR_CLASS = 'status-error';
const BUSY_CLASS = 'status-starting fa-beat-fade';
const INACTIVE_CLASS = 'status-inactive';
const ACTIVE_CLASS = 'status-active';
return function(app) {
if (!app) return '';
switch (app.installationState) {
case ISTATES.ERROR: return ERROR_CLASS;
case ISTATES.INSTALLED: {
if (app.debugMode) {
return INACTIVE_CLASS;
} else {
if (app.runState === RSTATES.RUNNING) {
if (!app.health) return BUSY_CLASS; // no data yet
if (app.type === APP_TYPES.LINK || app.health === HSTATES.HEALTHY) return ACTIVE_CLASS;
return ERROR_CLASS; // dead/exit/unhealthy
} else {
return INACTIVE_CLASS;
}
}
}
default: return BUSY_CLASS;
}
};
});
// this appears in the app grid
app.filter('installationStateLabel', function () {
return function(app) {
@@ -399,7 +429,7 @@ app.filter('errorSuggestion', function () {
};
});
app.filter('readyToUpdate', function () {
app.filter('canUpdate', function () {
return function (apps) {
return apps.every(function (app) {
return (app.installationState === ISTATES.ERROR) || (app.installationState === ISTATES.INSTALLED);
@@ -706,7 +736,10 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
};
function redirectOnMandatory2FA() {
if (Client.getConfig().mandatory2FA && !Client.getUserInfo().twoFactorAuthenticationEnabled) {
if (Client.getConfig().mandatory2FA) {
if (Client.getUserInfo().twoFactorAuthenticationEnabled) return; // user already has 2fa
if (Client.getUserInfo().source && $scope.config.external2FA) return; // 2fa is external
$location.path('/profile').search({ setup2fa: true });
}
}
@@ -745,35 +778,12 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
});
}
function redirectIfNeeded(status) {
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
if (status.restore.active || status.restore.errorMessage) { // show the error message in restore page
window.location.href = '/restore.html' + window.location.search;
} else if (status.adminFqdn) {
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
} else {
window.location.href = '/setupdns.html' + window.location.search;
}
return true;
}
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setupdns.html' + window.location.search;
return true;
}
return false;
}
// this loads the very first thing when accessing via IP or domain
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status)) return;
if (redirectIfNeeded(status, 'dashboard')) return; // we got redirected...
// check version and force reload if needed
if (!localStorage.version) {
+32 -36
View File
@@ -1,17 +1,11 @@
'use strict';
/* global $, angular, tld, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global $, angular, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, window, FileReader, document, redirectIfNeeded */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_CONTABO */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('RestoreController', ['$scope', 'Client', function ($scope, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
@@ -51,7 +45,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
password: '',
diskPath: '',
user: '',
seal: false,
seal: true,
port: 22,
privateKey: ''
};
@@ -61,27 +55,25 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.mountOptions.diskPath = '/dev/disk/by-uuid/' + newValue.uuid;
});
$scope.sysinfo = {
$scope.ipv4Config = {
provider: 'generic',
ipv4: '',
ip: '',
ifname: ''
};
$scope.sysinfoProvider = [
$scope.ipv6Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipProviders = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.s3Regions = REGIONS_S3;
$scope.wasabiRegions = REGIONS_WASABI;
$scope.doSpacesRegions = REGIONS_DIGITALOCEAN;
@@ -239,16 +231,17 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
var data = {
backupConfig: backupConfig,
remotePath: $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''),
version: version ? version[1] : '',
ipv4Config: $scope.ipv4Config,
ipv6Config: $scope.ipv6Config,
skipDnsSetup: $scope.skipDnsSetup,
setupToken: $scope.setupToken
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ip = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
Client.restore(backupConfig, $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''), version ? version[1] : '', sysinfoConfig, $scope.skipDnsSetup, $scope.setupToken, function (error) {
Client.restore(data, function (error) {
$scope.busy = false;
if (error) {
@@ -303,7 +296,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.busy = false;
$scope.error.generic = status.restore.errorMessage;
} else { // restore worked, redirect to admin page
window.location.href = '/';
window.location.href = 'https://' + status.adminFqdn + '/';
}
return;
}
@@ -362,14 +355,11 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'restore')) return; // redirected to some other view...
if (status.restore.active) return waitForRestore();
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage;
if (status.activated) {
window.location.href = '/';
return;
}
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage; // any previous restore error
Client.getProvisionBlockDevices(function (error, result) {
if (error) {
@@ -388,7 +378,13 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.initialized = true;
Client.detectIp(function (error, ip) { // this is never supposed to error
if (!error) $scope.ipv4Config.provider = ip.ipv4 ? 'generic' : 'noop';
if (!error) $scope.ipv6Config.provider = ip.ipv6 ? 'generic' : 'noop';
$scope.initialized = true;
});
});
});
}
+300 -84
View File
@@ -1,120 +1,336 @@
'use strict';
/* global angular */
/* global $ */
/* global $, angular, Clipboard, ENDPOINTS_OVH, window, FileReader, document, redirectIfNeeded */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.taskMinutesActive = null;
$scope.owner = {
error: null,
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.ipv4Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipv6Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipProviders = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'deSEC', value: 'desec' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
deSecToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
email: '',
displayName: '',
username: '',
password: '',
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
Client.createAdmin(data, function (error) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
setView('finished');
});
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
function redirectIfNeeded(status) {
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return;
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
// if we are here from https://ip/setup.html ,go to https://admin/setup.html
if (status.adminFqdn && status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/setup.html';
return true;
}
// if we don't have a domain yet, first go to domain setup
if (!status.adminFqdn) {
window.location.href = '/setupdns.html';
return true;
}
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
if (status.activated) {
window.location.href = '/';
return true;
}
return false;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
} else {
$scope.view = 'owner';
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
var provider = $scope.dnsCredentials.provider;
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'desec') {
config.token = $scope.dnsCredentials.deSecToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'ovh') {
config.endpoint = $scope.dnsCredentials.ovhEndpoint;
config.consumerKey = $scope.dnsCredentials.ovhConsumerKey;
config.appKey = $scope.dnsCredentials.ovhAppKey;
config.appSecret = $scope.dnsCredentials.ovhAppSecret;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
ipv4Config: $scope.ipv4Config,
ipv6Config: $scope.ipv6Config,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getProvisionStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
}
return;
}
if (!error) {
$scope.message = status.setup.message;
$scope.taskMinutesActive = (new Date() - new Date(status.setup.startTime)) / 60000;
}
setTimeout(waitForDnsSetup, 5000);
});
}
function init() {
Client.getProvisionStatus(function (error, status) {
$scope.state = 'waitingForBox';
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status)) return;
setView(search.view);
if (redirectIfNeeded(status, 'setup')) return; // redirected to some other view...
if (status.setup.active) return waitForDnsSetup();
$scope.error.setup = status.setup.errorMessage; // show any previous error
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
// aws marketplace made a policy change that they one cannot provide route53 IAM credentials
$scope.dnsCredentials.provider = 'wildcard';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.initialized = true;
$scope.provider = status.provider;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
Client.detectIp(function (error, ip) { // this is never supposed to error
if (!error) $scope.ipv4Config.provider = ip.ipv4 ? 'generic' : 'noop';
if (!error) $scope.ipv6Config.provider = ip.ipv6 ? 'generic' : 'noop';
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
init();
}]);
+5 -2
View File
@@ -71,6 +71,7 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$scope.error = null;
$scope.view = 'setup';
$scope.branding = null;
$scope.dashboardUrl = '';
$scope.profileLocked = !!search.profileLocked;
$scope.existingUsername = !!search.username;
@@ -122,8 +123,10 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$http.post(API_ORIGIN + '/api/v1/auth/setup_account', data).success(function (data, status) {
if (status !== 201) return error(data, status);
// set token to autologin
localStorage.token = data.accessToken;
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = data.accessToken;
$scope.dashboardUrl = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
$scope.view = 'done';
}).error(error);
-353
View File
@@ -1,353 +0,0 @@
'use strict';
/* global $, tld, angular, Clipboard, ENDPOINTS_OVH */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.isDomain = false;
$scope.isSubdomain = false;
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.sysinfo = {
provider: 'generic',
ipv4: '',
ifname: ''
};
$scope.sysinfoProvider = [
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
$scope.$watch('dnsCredentials.domain', function (newVal) {
if (!newVal) {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else if (!tld.getDomain(newVal) || newVal[newVal.length-1] === '.') {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else {
$scope.isDomain = true;
$scope.isSubdomain = tld.getDomain(newVal) !== newVal;
}
});
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
var provider = $scope.dnsCredentials.provider;
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'ovh') {
config.endpoint = $scope.dnsCredentials.ovhEndpoint;
config.consumerKey = $scope.dnsCredentials.ovhConsumerKey;
config.appKey = $scope.dnsCredentials.ovhAppKey;
config.appSecret = $scope.dnsCredentials.ovhAppSecret;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ip = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
ipv4Config: sysinfoConfig,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getProvisionStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
}
return;
}
$scope.message = status.setup.message;
setTimeout(waitForDnsSetup, 5000);
});
}
function initialize() {
Client.getProvisionStatus(function (error, status) {
if (error) {
// During domain migration, the box code restarts and can result in getStatus() failing temporarily
console.error(error);
$scope.state = 'waitingForBox';
return $timeout(initialize, 3000);
}
// domain is currently like a lock flag
if (status.adminFqdn) return waitForDnsSetup();
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
$scope.dnsCredentials.provider = 'route53';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.provider = status.provider;
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
initialize();
}]);
+13 -3
View File
@@ -23,7 +23,7 @@
height: 100%;
width: 100%;
text-align: center;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-family: "Noto Sans", Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.846;
}
@@ -51,9 +51,19 @@
<script type="text/javascript">
window.addEventListener('load', (event) => {
document.getElementById('message').innerHTML =
'You are seeing this page because the DNS record of <b>' + window.location.hostname + '</b> is set to this server\'s IP'
// https://stackoverflow.com/questions/37437890/check-if-url-has-domain-name-and-not-an-ip
const containsLetter = /[a-zA-z]/.test(window.location.hostname); // ignore technicality that IP can contain letters ! http://192.168.0x1.0x1 or http://0xc0.0xa8.1.1
const isIPv6 = location.hostname.startsWith('[') && location.hostname.endsWith(']');
let message;
if (!containsLetter || isIPv6) { // ipv4 or ipv6
message = 'You cannot view Cloudron dashboard by IP address. Instead, navigate to the domain you configured during setup i.e <b>https://my.domain.example</b> .'
+ '<br>If you do not remember your domain, SSH into your server and run <code>cloudron-support --owner-login</code> .'
} else { // hostname
message = 'You are seeing this page because the DNS record of <b>' + window.location.hostname + '</b> is set to this server\'s IP'
+ ' but Cloudron has no app configured for this domain.';
}
document.getElementById('message').innerHTML = message;
});
</script>
</head>
+1 -1
View File
@@ -15,7 +15,7 @@
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
+337 -323
View File
@@ -1,54 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Restore</title>
<meta name="description" content="Cloudron Restore">
<title>Cloudron Restore</title>
<meta name="description" content="Cloudron Restore">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/restore.js"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/restore.js"></script>
</head>
@@ -56,300 +53,317 @@
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container ng-cloak text-center" ng-show="busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
</div>
</div>
<div class="main-container ng-cloak text-center" ng-show="busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="initialized && !busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="configureBackupForm" role="form" novalidate ng-submit="restore()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
</div>
</div>
<div class="main-container ng-cloak" ng-show="initialized && !busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="configureBackupForm" role="form" novalidate ng-submit="restore()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
</div>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col-md-8 col-md-offset-2 text-center">
<input type="file" id="backupConfigFileInput" style="display:none"/>
<button type="button" class="btn btn-default" onclick="getElementById('backupConfigFileInput').click();">Upload Backup Config</button>
</div>
<br/>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col-md-8 col-md-offset-2 text-center">
<input type="file" id="backupConfigFileInput" style="display:none"/>
<button type="button" class="btn btn-default" onclick="getElementById('backupConfigFileInput').click();">Upload Backup Config</button>
</div>
<br/>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<p class="has-error text-center" ng-show="error">{{ error.generic }}</p>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<p class="has-error text-center" ng-show="error">{{ error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="provider" ng-options="a.value as a.name for a in storageProviders" ng-change=clearForm()></select>
</div>
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': error.mountPoint }" ng-show="provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">Mountpoint</label>
<input type="text" class="form-control" ng-model="mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="busy" placeholder="Folder where filesystem is mounted" ng-required="provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">Server IP or Hostname</label>
<input type="text" class="form-control" ng-model="mountOptions.host" id="configureBackupHost" name="host" ng-disabled="busy" placeholder="Server IP or hostname" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="provider === 'cifs'">
<label>
<input type="checkbox" ng-model="mountOptions.seal">Use seal encryption. Requires at least SMB v3</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">Remote Directory</label>
<input type="text" class="form-control" ng-model="mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="busy" placeholder="/share" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">Username ({{ provider }})</label>
<input type="text" class="form-control" ng-model="mountOptions.username" id="configureBackupUsername" name="cifsUsername" ng-disabled="busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">Password ({{ provider }})</label>
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4' || provider === 'xfs'">
<label class="control-label" for="inputConfigureDiskPath">Disk Path</label>
<input type="text" class="form-control" ng-model="mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'ext4' || provider === 'xfs'">
</div>
<!-- Disk -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'disk'">
<label class="control-label">Device</label>
<select class="form-control" ng-model="disk" ng-options="item as item.label for item in blockDevices track by item.path" ng-required="provider === 'disk'"></select>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">SSH Port</label>
<input type="number" class="form-control" ng-model="mountOptions.port" id="configureBackupPort" name="port" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">SSH User</label>
<input type="text" class="form-control" ng-model="mountOptions.user" id="configureBackupUser" name="user" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">SSH Private Key</label>
<textarea class="form-control" ng-model="mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="busy"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': error.backupFolder }" ng-show="provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'filesystem'">
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="busy" placeholder="URL" ng-required="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': error.bucket }" ng-show="s3like(provider) || provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.prefix }" ng-show="provider !== 'filesystem' && provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="busy" ng-required="provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">Region</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="region" ng-disabled="busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="busy" ng-required="provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="busy" ng-required="provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="busy" ng-required="provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="busy" ng-required="provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="busy" ng-required="provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="busy" ng-required="provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="busy" ng-required="provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="busy" ng-required="provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'contabo-objectstorage'">
<label class="control-label" for="inputConfigureBackupContaboRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupContaboRegion" ng-model="endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="busy" ng-required="provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.accessKeyId }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.secretAccessKey }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.gcsKeyInput }" ng-show="provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="busy" ng-required="provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="key = ''" ng-model="format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
<label class="control-label" for="inputConfigureBackupPassword">Encryption password <span ng-hide="encrypted">(optional)</span></label>
<input type="text" class="form-control" ng-model="password" id="inputConfigureBackupPassword" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups" ng-required="encrypted">
</div>
<div class="checkbox" ng-show="format === 'rsync' && password.length !== 0">
<label>
<input type="checkbox" ng-model="encryptedFilenames">Decrypt Filenames</input>
</label>
</div>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="skipDnsSetup"><b>Dry run</b></sup>
</label>
<br/>
<small>When enabled, apps are restored but the DNS records are not updated to point to this server. To access the dashboard, this browser's host must have an entry in <code>/etc/hosts</code> for the dashboard domain to this server's IP.
See the <a href="https://docs.cloudron.io/backups/#dry-run" target="_blank">docs</a> for more information.</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">IP Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.provider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.provider === 'fixed'" ng-class="{ 'has-error': error.ipv4 }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.ipv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'fixed'">
<p class="has-error" ng-show="error.ipv4">{{ error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.provider === 'network-interface'" ng-class="{ 'has-error': error.ifname }">
<label class="control-label">Interface Name</label>
<input type="text" class="form-control" ng-model="sysinfo.ifname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'network-interface'">
<p class="has-error" ng-show="error.ifname">{{ error.ifname }}</p>
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="configureBackupForm.$invalid"/><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Restore</button>
</div>
</div>
</form>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="provider" ng-options="a.value as a.name for a in storageProviders" ng-change=clearForm()></select>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': error.mountPoint }" ng-show="provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">Mountpoint</label>
<input type="text" class="form-control" ng-model="mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="busy" placeholder="Folder where filesystem is mounted" ng-required="provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">Server IP or Hostname</label>
<input type="text" class="form-control" ng-model="mountOptions.host" id="configureBackupHost" name="host" ng-disabled="busy" placeholder="Server IP or hostname" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="provider === 'cifs'">
<label>
<input type="checkbox" ng-model="mountOptions.seal">Use seal encryption. Requires at least SMB v3</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">Remote Directory</label>
<input type="text" class="form-control" ng-model="mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="busy" placeholder="/share" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">Username ({{ provider }})</label>
<input type="text" class="form-control" ng-model="mountOptions.username" id="configureBackupUsername" name="cifsUsername" ng-disabled="busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">Password ({{ provider }})</label>
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4' || provider === 'xfs'">
<label class="control-label" for="inputConfigureDiskPath">Disk Path</label>
<input type="text" class="form-control" ng-model="mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'ext4' || provider === 'xfs'">
</div>
<!-- Disk -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'disk'">
<label class="control-label">Device</label>
<select class="form-control" ng-model="disk" ng-options="item as item.label for item in blockDevices track by item.path" ng-required="provider === 'disk'"></select>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">SSH Port</label>
<input type="number" class="form-control" ng-model="mountOptions.port" id="configureBackupPort" name="port" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">SSH User</label>
<input type="text" class="form-control" ng-model="mountOptions.user" id="configureBackupUser" name="user" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">SSH Private Key</label>
<textarea class="form-control" ng-model="mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="busy"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': error.backupFolder }" ng-show="provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'filesystem'">
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="busy" placeholder="URL" ng-required="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': error.bucket }" ng-show="s3like(provider) || provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.prefix }" ng-show="provider !== 'filesystem' && provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="busy" ng-required="provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">Region</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="region" ng-disabled="busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="busy" ng-required="provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="busy" ng-required="provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="busy" ng-required="provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="busy" ng-required="provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="busy" ng-required="provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="busy" ng-required="provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="busy" ng-required="provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="busy" ng-required="provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'contabo-objectstorage'">
<label class="control-label" for="inputConfigureBackupContaboRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupContaboRegion" ng-model="endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="busy" ng-required="provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.accessKeyId }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.secretAccessKey }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.gcsKeyInput }" ng-show="provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="busy" ng-required="provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="key = ''" ng-model="format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
<label class="control-label" for="inputConfigureBackupPassword">Encryption password <span ng-hide="encrypted">(optional)</span></label>
<input type="text" class="form-control" ng-model="password" id="inputConfigureBackupPassword" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups" ng-required="encrypted">
</div>
<div class="checkbox" ng-show="format === 'rsync' && password.length !== 0">
<label>
<input type="checkbox" ng-model="encryptedFilenames">Decrypt Filenames</input>
</label>
</div>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="skipDnsSetup"><b>Dry run</b></sup>
</label>
<br/>
<small>When enabled, apps are restored but the DNS records are not updated to point to this server. To access the dashboard, this browser's host must have an entry in <code>/etc/hosts</code> for the dashboard domain to this server's IP.
See the <a href="https://docs.cloudron.io/backups/#dry-run" target="_blank">docs</a> for more information.</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
<div uib-collapse="!advancedVisible">
<!-- IPv4 provider -->
<div class="form-group">
<label class="control-label">IPv4 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv4Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv4 Fixed -->
<div class="form-group" ng-show="ipv4Config.provider === 'fixed'">
<label class="control-label">IPv4 Address</label>
<input type="text" class="form-control" ng-model="ipv4Config.ip" name="ipv4" ng-required="ipv4Config.provider === 'fixed'">
</div>
<!-- IPv4 Network Interface -->
<div class="form-group" ng-show="ipv4Config.provider === 'network-interface'">
<label class="control-label">IPv4 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv4Config.ifname" name="ifname4" ng-required="ipv4Config.provider === 'network-interface'">
</div>
<!-- IPv6 provider -->
<div class="form-group">
<label class="control-label">IPv6 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv6 Fixed -->
<div class="form-group" ng-show="ipv6Config.provider === 'fixed'">
<label class="control-label">IPv6 Address</label>
<input type="text" class="form-control" ng-model="ipv6Config.ip" name="ipv6" ng-required="ipv6Config.provider === 'fixed'">
</div>
<!-- IPv6 Network Interface -->
<div class="form-group" ng-show="ipv6Config.provider === 'network-interface'">
<label class="control-label">IPv6 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv6Config.ifname" name="ifname6" ng-required="ipv6Config.provider === 'network-interface'">
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="configureBackupForm.$invalid"/><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Restore</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+346 -108
View File
@@ -1,152 +1,390 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Setup</title>
<meta name="description" content="Cloudron Setup">
<title>Cloudron Domain Setup</title>
<meta name="description" content="Cloudron Domain Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setup.js"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setup.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupController">
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container" ng-show="initialized">
<div class="row" ng-show="view === 'owner'">
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
<br/>
<br/>
<p ng-show="taskMinutesActive >= 4">
If setup appears stuck, it can be restarted by running <code class="clipboard hand" data-clipboard-text="systemctl restart box" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">systemctl restart box</code> and reloading this page.
</p>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="state === 'initialized'">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form role="form" name="ownerForm" ng-submit="owner.submit()" novalidate>
<form name="dnsCredentialsForm" role="form" novalidate ng-submit="setDnsCredentials()" autocomplete="off">
<div class="row">
<div class="col-md-12 text-center">
<h1>Welcome to Cloudron</h1>
<h3>Set up Admin Account</h3>
<p class="has-error text-center" ng-show="owner.error.generic">{{ owner.error.generic }}</p>
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Domain Setup</h1>
<p class="has-error text-center" ng-show="error.setup">{{ error.setup }}</p>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': ownerForm.displayName.$dirty && ownerForm.displayName.$invalid }">
<label class="control-label" for="inputDisplayName">Full Name</label>
<input type="text" class="form-control" ng-model="owner.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" ng-disabled="owner.busy" autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.email.$dirty && ownerForm.email.$invalid) || (!ownerForm.email.$dirty && owner.error.email) }">
<label class="control-label" for="inputEmail">Email <sup><a ng-href="https://docs.cloudron.io/installation/#admin-account" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="email" class="form-control" ng-model="owner.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" ng-disabled="owner.busy">
<small>A valid email is required for Let's Encrypt certificates. This email is local to your Cloudron. </small>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.username.$dirty && ownerForm.username.$invalid) || (!ownerForm.username.$dirty && owner.error.username) }">
<label class="control-label" for="inputUsername">Username</label>
<input type="text" class="form-control" ng-model="owner.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off" ng-disabled="owner.busy">
<small>{{ owner.error.username }}</small>
</div>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': ownerForm.password.$dirty && ownerForm.password.$invalid }">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" ng-model="owner.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off" ng-disabled="owner.busy" password-reveal>
<small><span ng-show="ownerForm.password.$dirty && ownerForm.password.$invalid">Password must be at least 8 characters</span> &nbsp;</small>
<div class="col-md-10 col-md-offset-1">
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<label class="control-label">Domain <sup><a ng-href="https://docs.cloudron.io/installation/#domain-setup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<div class="text-danger" ng-show="dnsCredentials.domain.indexOf('my.') === 0 && dnsCredentials.domain.length > 3">Are you sure about this domain? The dashboard will be at <b>my.{{ dnsCredentials.domain }}</b></div>
<p style="margin-top: 5px; font-size: 13px;">
Apps will be installed on subdomains of this domain. The dashboard will be available on the <b>my</b> subdomain. You can add more domains later.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<p class="has-error text-center" ng-show="error.dnsCredentials">{{ error.dnsCredentials }}</p>
<div class="form-group">
<label class="control-label">DNS Provider</label>
<select class="form-control" ng-model="dnsCredentials.provider" ng-options="a.value as a.name for a in dnsProvider" ng-disabled="dnsCredentials.busy" ng-change="setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div ng-if="provider === 'ami'" ng-show="dnsCredentials.provider === 'route53'">
<b class="has-error">This feature is disabled in AWS Marketplace AMI. <a href="https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html" target="_blank">AWS Marketplace Policy</a> disallows
AMIs from requesting IAM credentials from users to access Route53 hosted domains. Please use the Wildcard or Manual provider instead.</b>
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="dnsCredentials.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-required="dnsCredentials.provider === 'gcdns'" ng-disabled="dnsCredentials.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.gandiApiKey.$dirty && dnsCredentialsForm.gandiApiKey.$invalid }" ng-show="dnsCredentials.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.gandiApiKey" name="gandiApiKey" placeholder="API Key" ng-required="dnsCredentials.provider === 'gandi'" ng-disabled="dnsCredentials.busy">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiKey.$dirty && dnsCredentialsForm.godaddyApiKey.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiKey" name="godaddyApiKey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiSecret.$dirty && dnsCredentialsForm.godaddyApiSecret.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiSecret" name="godaddyApiSecret" placeholder="API Secret" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupCustomerNumber.$dirty && dnsCredentialsForm.netcupCustomerNumber.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">Customer Number</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiKey.$dirty && dnsCredentialsForm.netcupApiKey.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiKey" name="netcupApiKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiPassword.$dirty && dnsCredentialsForm.netcupApiPassword.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Password</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiPassword" name="netcupApiPassword" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="dnsCredentials.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="dnsCredentials.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="dnsCredentials.busy">
</div>
<div class="checkbox" ng-show="dnsCredentials.provider === 'cloudflare'">
<label>
<input type="checkbox" ng-model="dnsCredentials.cloudflareDefaultProxyStatus"> Enable proxying for new DNS records
<sup><a ng-href="https://docs.cloudron.io/domains/#cloudflare-dns" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComUsername.$dirty && dnsCredentialsForm.nameComUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComUsername" name="nameComUsername" placeholder="Name.com Username" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComToken.$dirty && dnsCredentialsForm.nameComToken.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComToken" name="nameComToken" placeholder="Name.com API Token" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapUsername.$dirty && dnsCredentialsForm.namecheapUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapUsername" name="namecheapUsername" placeholder="Namecheap Username" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapApiKey.$dirty && dnsCredentialsForm.namecheapApiKey.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="dnsCredentials.provider === 'namecheap'"><b>The server IP needs to be whitelisted for this API Key.</b></p>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapApiKey" name="namecheapApiKey" placeholder="Namecheap API Key" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Linode -->
<p class="form-group" ng-show="dnsCredentials.provider === 'linode'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.linodeToken" name="linodeToken" ng-required="dnsCredentials.provider === 'linode'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Bunny -->
<p class="form-group" ng-show="dnsCredentials.provider === 'bunny'">
<label class="control-label">Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- dnsimple -->
<p class="form-group" ng-show="dnsCredentials.provider === 'dnsimple'">
<label class="control-label">Access Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-required="dnsCredentials.provider === 'dnsimple'" ng-disabled="dnsCredentials.busy">
</p>
<!-- OVH -->
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">Endpoint</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="dnsCredentials.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'"></select>
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Consumer Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppKey" name="ovhAppKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppSecret" name="ovhAppSecret" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<!-- Porkbun -->
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.hetznerToken" name="hetznerToken" ng-required="dnsCredentials.provider === 'hetzner'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Vultr -->
<p class="form-group" ng-show="dnsCredentials.provider === 'vultr'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.vultrToken" name="vultrToken" ng-required="dnsCredentials.provider === 'vultr'" ng-disabled="dnsCredentials.busy">
</p>
<!-- deSEC -->
<p class="form-group" ng-show="dnsCredentials.provider === 'desec'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.deSecToken" name="deSecToken" ng-required="dnsCredentials.provider === 'desec'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Wildcard -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'wildcard'">
<span>Set up A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}.</b> and <b>{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.</span>
</p>
<!-- Manual -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'manual'">
<span>Set up an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.<br/></span>
</p>
<p class="small text-info" ng-show="needsPort80(dnsCredentials.provider, dnsCredentials.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<div ng-show="provider === 'ami'">
<h3 class="text-center">Owner verification</h3>
<p class="has-error text-center" ng-show="error.ami">{{ error.ami }}</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error.ami) }">
<label class="control-label">EC2 Instance Id</label>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="i-0123456789abcdefg" ng-minlength="1" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p style="margin-top: 5px; font-size: 13px;">Provide the EC2 instance id to verify you have access to this server.</p>
</div>
<br/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">DNS Zone Name (Optional) <sup><a ng-href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.zoneName" name="zoneName" placeholder="Defaults to TLD" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="dnsCredentials.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider" ng-disabled="dnsCredentials.busy"></select>
</div>
<!-- IPv4 provider -->
<div class="form-group">
<label class="control-label">IPv4 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv4Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv4 Fixed -->
<div class="form-group" ng-show="ipv4Config.provider === 'fixed'">
<label class="control-label">IPv4 Address</label>
<input type="text" class="form-control" ng-model="ipv4Config.ip" name="ipv4" ng-required="ipv4Config.provider === 'fixed'">
</div>
<!-- IPv4 Network Interface -->
<div class="form-group" ng-show="ipv4Config.provider === 'network-interface'">
<label class="control-label">IPv4 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv4Config.ifname" name="ifname4" ng-required="ipv4Config.provider === 'network-interface'">
</div>
<!-- IPv6 provider -->
<div class="form-group">
<label class="control-label">IPv6 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv6 Fixed -->
<div class="form-group" ng-show="ipv6Config.provider === 'fixed'">
<label class="control-label">IPv6 Address</label>
<input type="text" class="form-control" ng-model="ipv6Config.ip" name="ipv6" ng-required="ipv6Config.provider === 'fixed'">
</div>
<!-- IPv6 Network Interface -->
<div class="form-group" ng-show="ipv6Config.provider === 'network-interface'">
<label class="control-label">IPv6 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv6Config.ifname" name="ifname6" ng-required="ipv6Config.provider === 'network-interface'">
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-success" ng-disabled="ownerForm.$invalid || owner.busy"><i class="fa fa-circle-notch fa-spin" ng-show="owner.busy"></i> Create Admin</button>
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
</div>
</form>
</div>
</div>
</div>
<div class="row" ng-show="view === 'finished'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px 40px;">
<div class="row">
<div class="col-md-12 text-center">
<h1>Cloudron is ready to use</h1>
</div>
</div>
<p>
&nbsp; &nbsp; Before you start:
<ul class="fa-ul">
<li><i class="fa-li fa fa-users"></i>
<b>User management</b>: Cloudron has a central user directory. When installing an app,
you can set it up to authenticate against this directory.
</li>
<br/>
<li><i class="fa-li fa fa-envelope-open"></i>
<b>Email Configuration</b>: Apps are configured to send email based on the settings in the Email view.
This saves you the trouble of having to configure mail settings inside each app.
</li>
<br/>
<li><i class="fa-li fa fa-archive"></i>
<b>Backups</b>: Store your backups on storage services completely independent from your server.
You can use backups to seamlessly migrate your setup to another server.
</li>
<br/>
<li><i class="fa-li fa fa-birthday-cake"></i>
<b>Updates</b>: The Cloudron team tracks upstream releases and publishes app updates after testing.
Your apps are kept fresh &amp; secure.
</li>
</ul>
</p>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a class="btn btn-success" href="/">Proceed to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="text-center">
<footer class="text-center" ng-show="state === 'waitingForDnsSetup' || state === 'initialized'">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
+2 -2
View File
@@ -14,7 +14,7 @@
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -147,7 +147,7 @@
<br/>
<h2>{{ 'setupAccount.success.title' | tr }}</h2>
<br/>
<a href="/" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
<a ng-href="dashboardUrl" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
</div>
</div>
</div>
-359
View File
@@ -1,359 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Domain Setup</title>
<meta name="description" content="Cloudron Domain Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setupdns.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup' || state === 'waitingForBox'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard at my.{{dnsCredentials.domain}}.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="state === 'initialized'">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="dnsCredentialsForm" role="form" novalidate ng-submit="setDnsCredentials()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Domain Setup</h1>
<p class="has-error text-center" ng-show="error.setup">{{ error.setup }}</p>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<label class="control-label">Domain <sup><a ng-href="https://docs.cloudron.io/installation/#domain-setup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<div class="text-danger" ng-show="dnsCredentials.domain.indexOf('my.') === 0 && dnsCredentials.domain.length > 3">Are you sure about this domain? The dashboard will be at <b>my.{{ dnsCredentials.domain }}</b></div>
<p style="margin-top: 5px; font-size: 13px;">
Apps will be installed on subdomains of this domain. The dashboard will be available on the <b>my</b> subdomain. You can add more domains later.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<p class="has-error text-center" ng-show="error.dnsCredentials">{{ error.dnsCredentials }}</p>
<div class="form-group">
<label class="control-label">DNS Provider</label>
<select class="form-control" ng-model="dnsCredentials.provider" ng-options="a.value as a.name for a in dnsProvider" ng-disabled="dnsCredentials.busy" ng-change="setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="dnsCredentials.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-required="dnsCredentials.provider === 'gcdns'" ng-disabled="dnsCredentials.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.gandiApiKey.$dirty && dnsCredentialsForm.gandiApiKey.$invalid }" ng-show="dnsCredentials.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.gandiApiKey" name="gandiApiKey" placeholder="API Key" ng-required="dnsCredentials.provider === 'gandi'" ng-disabled="dnsCredentials.busy">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiKey.$dirty && dnsCredentialsForm.godaddyApiKey.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiKey" name="godaddyApiKey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiSecret.$dirty && dnsCredentialsForm.godaddyApiSecret.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiSecret" name="godaddyApiSecret" placeholder="API Secret" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupCustomerNumber.$dirty && dnsCredentialsForm.netcupCustomerNumber.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">Customer Number</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiKey.$dirty && dnsCredentialsForm.netcupApiKey.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiKey" name="netcupApiKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiPassword.$dirty && dnsCredentialsForm.netcupApiPassword.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Password</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiPassword" name="netcupApiPassword" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="dnsCredentials.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">Api Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="dnsCredentials.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="dnsCredentials.busy">
</div>
<div class="checkbox" ng-show="dnsCredentials.provider === 'cloudflare'">
<label>
<input type="checkbox" ng-model="dnsCredentials.cloudflareDefaultProxyStatus"> Enable proxying for new DNS records
<sup><a ng-href="https://docs.cloudron.io/domains/#cloudflare-dns" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComUsername.$dirty && dnsCredentialsForm.nameComUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComUsername" name="nameComUsername" placeholder="Name.com Username" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComToken.$dirty && dnsCredentialsForm.nameComToken.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComToken" name="nameComToken" placeholder="Name.com API Token" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapUsername.$dirty && dnsCredentialsForm.namecheapUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapUsername" name="namecheapUsername" placeholder="Namecheap Username" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapApiKey.$dirty && dnsCredentialsForm.namecheapApiKey.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="dnsCredentials.provider === 'namecheap'"><b>The server IP needs to be whitelisted for this API Key.</b></p>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapApiKey" name="namecheapApiKey" placeholder="Namecheap API Key" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Linode -->
<p class="form-group" ng-show="dnsCredentials.provider === 'linode'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.linodeToken" name="linodeToken" ng-required="dnsCredentials.provider === 'linode'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Bunny -->
<p class="form-group" ng-show="dnsCredentials.provider === 'bunny'">
<label class="control-label">Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- dnsimple -->
<p class="form-group" ng-show="dnsCredentials.provider === 'dnsimple'">
<label class="control-label">Access Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-required="dnsCredentials.provider === 'dnsimple'" ng-disabled="dnsCredentials.busy">
</p>
<!-- OVH -->
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">Endpoint</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="dnsCredentials.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'"></select>
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Consumer Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppKey" name="ovhAppKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppSecret" name="ovhAppSecret" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<!-- Porkbun -->
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.hetznerToken" name="hetznerToken" ng-required="dnsCredentials.provider === 'hetzner'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Vultr -->
<p class="form-group" ng-show="dnsCredentials.provider === 'vultr'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.vultrToken" name="vultrToken" ng-required="dnsCredentials.provider === 'vultr'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Wildcard -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'wildcard'">
<span>Set up A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}.</b> and <b>{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.</span>
</p>
<!-- Manual -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'manual'">
<span>Set up an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.<br/></span>
</p>
<p class="small text-info" ng-show="needsPort80(dnsCredentials.provider, dnsCredentials.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<div ng-show="provider === 'ami'">
<h3 class="text-center">Owner verification</h3>
<p class="has-error text-center" ng-show="error.ami">{{ error.ami }}</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error.ami) }">
<label class="control-label">EC2 Instance Id</label>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="i-0123456789abcdefg" ng-minlength="1" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p style="margin-top: 5px; font-size: 13px;">Provide the EC2 instance id to verify you have access to this server.</p>
</div>
<br/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">Zone Name (Optional) <sup><a ng-href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.zoneName" name="zoneName" placeholder="{{dnsCredentials.domain | zoneName}}" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="dnsCredentials.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider" ng-disabled="dnsCredentials.busy"></select>
</div>
<div class="form-group">
<label class="control-label">IP Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.provider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.provider === 'fixed'" ng-class="{ 'has-error': error.ipv4 }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.ipv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'fixed'">
<p class="has-error" ng-show="error.ipv4">{{ error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.provider === 'network-interface'" ng-class="{ 'has-error': error.ifname }">
<label class="control-label">Interface Name</label>
<input type="text" class="form-control" ng-model="sysinfo.ifname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'network-interface'">
<p class="has-error" ng-show="error.ifname">{{ error.ifname }}</p>
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title> Login to <%= title %> </title>
<link href="<%= icon %>" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/3rdparty/fontawesome/css/all.css"/>
</head>
<body>
<div class="layout-root">
<div class="layout-content">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= icon %>"/>
<br/>
<h1><small>{{ login.loginTo }}</small> <%= title %></h1>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 class="has-error" id="message"></h4>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form name="loginForm" onsubmit="return onLogin(event)">
<div class="form-group">
<label class="control-label" for="inputUsername">{{ login.username }}</label>
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
</div>
<div class="form-group">
<label class="control-label" for="inputPassword">{{ login.password }}</label>
<input type="password" class="form-control" name="password" id="inputPassword" required>
</div>
<div class="form-group">
<label class="control-label" for="inputTotpToken">{{ login.2faToken }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" value="">
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login"><i id="busyIndicator" class="hide fa fa-circle-notch fa-spin"></i> {{ login.signInAction }}</button>
</form>
<!-- <a ng-href="" class="hand" ng-click="showPasswordReset()">Reset password</a> -->
</div>
</div>
</div>
</div>
<script>
function onLogin(event) {
event.preventDefault();
var username = document.getElementById('inputUsername').value;
var password = document.getElementById('inputPassword').value;
var totpToken = document.getElementById('inputTotpToken').value;
document.getElementById('busyIndicator').classList.remove('hide');
fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
redirect: 'manual',
body: JSON.stringify({ username: username, password: password, totpToken: totpToken })
}).then(function (response) {
if (response.status === 401 || response.status === 403) {
document.getElementById('message').innerText = "{{ login.errorIncorrectCredentials }}"; // FIXME this needs proper escaping for translated strings, single quotes break easily!
document.getElementById('busyIndicator').classList.add('hide');
return;
}
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
window.location.href = search.redirect || '/';
});
return false;
}
// patch up for password reveal see dashboard/js/utils.js
var element = document.getElementById('inputPassword');
var svgEye = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye" class="svg-inline--fa fa-eye fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z"></path></svg>';
var svgEyeSlash = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye-slash" class="svg-inline--fa fa-eye-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path></svg>';
var eye = document.createElement('i');
eye.innerHTML = svgEyeSlash;
eye.style.width = '18px';
eye.style.height = '18px';
eye.style.position = 'relative';
eye.style.float = 'right';
eye.style.marginTop = '-24px';
eye.style.marginRight = '10px';
eye.style.cursor = 'pointer';
eye.addEventListener('click', function () {
if (element.type === 'password') {
element.type = 'text';
eye.innerHTML = svgEye;
} else {
element.type = 'password';
eye.innerHTML = svgEyeSlash;
}
});
element.parentNode.style.position = 'relative';
element.parentNode.insertBefore(eye, element.nextSibling);
</script>
</body>
</html>
+206 -81
View File
@@ -9,13 +9,13 @@ $brand-info: #3995b1 !default;
$brand-warning: #f0ad4e !default;
$brand-danger: #ff4c4c !default;
$body-bg: #E5E5E5;
$font-family-sans-serif: Roboto, Helvetica, Arial, sans-serif;
$font-family-heading: Roboto-Light, Helvetica, Arial, sans-serif;
$body-bg: #f4f4f4;
$font-family-sans-serif: "Noto Sans", Helvetica, Arial, sans-serif;
$font-family-heading: "Noto Sans Light", Helvetica, Arial, sans-serif;
$navbar-default-link-color: #428BCA !default;
$navbar-default-link-color: $brand-primary !default;
$navbar-default-link-hover-color: #62bdfc !default;
$navbar-default-link-active-color: #62bdfc !default;
$navbar-default-link-active-color: #428BCA !default;
$navbar-default-brand-color: #777 !default;
$btn-default-bg: transparent !default;
@@ -54,27 +54,28 @@ $state-danger-text: $brand-danger;
$state-danger-border: $brand-danger;
@import "bootstrap";
@font-face {
font-family: Roboto;
src: url(3rdparty/Roboto-Regular.ttf);
}
@font-face {
font-family: Roboto-Light;
src: url(3rdparty/Roboto-Light.ttf);
}
@font-face {
font-family: Roboto;
font-weight: 700;
src: url(3rdparty/Roboto-Bold.ttf);
}
@import "3rdparty/noto-sans/index.css";
// ----------------------------
// Bootstrap extension
// ----------------------------
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
font-family: $font-family-heading;
font-weight: 400;
}
.hide-mobile {
@media(max-width:767px) {
display: none;
}
}
.table-hover > tbody > tr:hover {
background-color: $body-bg;
}
.text-monospace {
font-family: $font-family-monospace;
}
@@ -111,6 +112,7 @@ $state-danger-border: $brand-danger;
white-space: nowrap;
overflow: hidden;
max-width: 300px;
vertical-align: middle !important;
}
.wrap-table-cell {
@@ -175,16 +177,7 @@ html, body {
}
.view-header-filter-bar {
position: absolute;
right: 14px;
margin-top: 5px;
padding: 5px;
padding-top: 0;
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0,0,0,.15);
border-radius: 2px;
box-shadow: 0 6px 12px rgba(0,0,0,.175);
text-align: right;
}
.view-header-search-bar {
@@ -278,9 +271,11 @@ html, body {
display: block;
width: 100%;
flex-grow: 0;
background-color: white;
border-color: white;
.navbar-collapse {
background-color: #F8F8F8;
background-color: white;
}
@media(min-width:768px) {
@@ -360,6 +355,51 @@ textarea {
// Apps view
// ----------------------------
.app-list {
width: 100%;
margin-top: 20px !important;
th {
white-space: nowrap;
}
.app-list-item {
.app-list-item-icon {
height: 32px;
}
.app-list-app-link-cell {
padding: 0;
}
.app-list-item-fqdn {
visibility: hidden;
color: $text-muted;
margin-left: 20px;
font-size: 12px;
}
&:hover .app-list-item-fqdn {
visibility: visible;
}
.app-list-app-link {
display: inline-block;
color: $text-dark;
padding: 8px;
&:hover {
text-decoration: none;
}
}
.app-list-item-progress {
height: 5px;
margin: 0 8px;
}
}
}
.app-grid {
display: flex;
flex-wrap: wrap;
@@ -494,6 +534,28 @@ textarea {
}
}
.app-checklist-badge {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: -12px;
top: -12px;
z-index: 2;
font-size: 14px;
height: 24px;
width: 24px;
color: white;
cursor: pointer;
background-color: $brand-danger;
border-radius: 34px;
transition: all 100ms ease-out;
&:hover {
transform: scale(1.4);
}
}
.app-postinstall-message {
max-height: 500px;
overflow-x: none;
@@ -506,11 +568,6 @@ textarea {
line-height: 1.4;
}
.app-info-meta {
margin-left: 4px;
color: $text-muted;
}
.app-info-icon {
float: left;
min-height: 64px;
@@ -546,6 +603,7 @@ multiselect {
cursor: pointer;
width: 64px;
height: 64px;
margin-bottom: 5px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
@@ -612,6 +670,10 @@ multiselect {
padding: 10px;
background-color: white;
@media (prefers-color-scheme: dark) {
background-color: #1c1c1c;
}
@media(min-width:768px) {
background-color: transparent;
width: auto;
@@ -635,6 +697,26 @@ multiselect {
}
}
.checklist-item {
padding: 8px;
border: none;
border-left: 2px solid rgb(255, 76, 76);
background-color: #ff000014;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.checklist-item-acknowledged {
border-left: 2px solid $brand-success;
background-color: transparent;
}
.checklist-item > span > p {
margin: 0;
}
// ----------------------------
// Mail view
// ----------------------------
@@ -695,7 +777,7 @@ multiselect {
}
.card {
min-height: 523px;
min-height: 558px;
}
@media(min-width:768px) {
@@ -721,11 +803,14 @@ multiselect {
h1 {
margin-right: 10px;
line-height: 0.7;
margin-bottom: 0px;
margin-top: 16px;
padding: 4px 0;
line-height: 1;
font-size: 30px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
overflow: hidden visible;
a {
white-space: nowrap;
@@ -801,7 +886,7 @@ multiselect {
&:hover,
&:focus {
text-decoration: none;
background-color: #f3f3f3;
background-color: #e9ebed;
box-shadow: -4px 3px 5px -2px rgba(0,0,0,.1);
}
@@ -873,7 +958,7 @@ multiselect {
.appstore-toolbar-content {
display: flex;
margin: auto;
max-width: 1200px;
max-width: 1400px;
> * {
margin: 0 10px;
@@ -911,7 +996,7 @@ multiselect {
margin: auto;
overflow: auto;
height: calc(100% - 65px); // offset navigation bar
max-width: 1200px;
max-width: 1400px;
h2 {
font-size: 20px;
@@ -949,6 +1034,11 @@ multiselect {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-bottom: 0px;
margin-top: 16px;
padding: 4px 0;
line-height: 1;
font-family: $font-family-sans-serif;
}
.appstore-item-content-tagline {
@@ -958,11 +1048,18 @@ multiselect {
}
.appstore-item-content-icon {
width: 100px;
min-width: 100px;
max-width: 100px;
width: 90px;
min-width: 90px;
max-width: 90px;
padding-left: 10px;
padding-right: 10px;
> .app-icon {
width: 70px;
height: 70px;
min-width: 70px;
min-height: 70px;
}
}
.appstore-category-link {
@@ -1084,6 +1181,10 @@ multiselect {
margin-bottom: 15px;
padding: 10px 15px;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
@media(max-width:767px) {
padding: 0;
}
}
.card-small {
@@ -1310,7 +1411,7 @@ select.purpose:invalid {
footer {
flex-grow: 0;
background-color: #f8f8f8;
background-color: white;
width: 100%;
color: #555;
max-height: 30px;
@@ -1436,6 +1537,39 @@ footer {
// Settings
// ----------------------------
.picture-edit-indicator {
position: absolute;
bottom: -4px;
right: -4px;
border-radius: 20px;
padding: 5px;
color: $text-dark;
background-color: white;
transition: all 250ms;
}
div:hover > .picture-edit-indicator {
color: white;
background: $brand-primary;
transform: scale(1.2);
}
.info-edit-indicator {
float: right;
border-radius: 20px;
padding: 5px;
color: $text-dark;
background-color: white;
transition: all 250ms;
cursor: pointer;
}
.info-edit-indicator:hover {
color: white;
background: $brand-primary;
transform: scale(1.2);
}
.settings-avatar {
position: relative;
cursor: pointer;
@@ -1453,23 +1587,6 @@ footer {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(127, 127, 127 ,0.3);
background-image: url('/img/plus.png');
background-repeat: no-repeat;
background-position: center;
transition: all 150ms;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.settings-avatar-selector {
@@ -1676,23 +1793,6 @@ footer {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(127, 127, 127 ,0.3);
background-image: url('/img/plus.png');
background-repeat: no-repeat;
background-position: center;
transition: all 150ms;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.branding-avatar-selector {
@@ -1737,6 +1837,23 @@ footer {
}
}
.branding-background {
position: relative;
cursor: pointer;
width: 256px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
border: 1px solid gray;
border-radius: 3px;
img {
display: block;
width: 100%;
height: 100%;
}
}
// ----------------------------
// Tag Input
// ----------------------------
@@ -2215,6 +2332,14 @@ tag-input {
}
}
.app-list .app-list-item .app-list-app-link {
color: $textColor;
}
.app-list .app-list-item:hover .app-list-app-link {
color: white;
}
footer, .card, .app-configure-links div.active {
background-color: $backgroundDark;
}
+147 -47
View File
@@ -22,13 +22,17 @@
"auth": {
"nosso": "Die App verwendet eine eigene Benutzerverwaltung",
"email": "Mit E-Mail-Adresse anmelden",
"sso": "Mit Cloudron Zugangsdaten anmelden"
"sso": "Mit Cloudron Zugangsdaten anmelden",
"openid": "Mit Cloudron OpenID anmelden"
},
"addAppAction": "App hinzufügen",
"addAppproxyAction": "App Proxy hinzufügen",
"addApplinkAction": "App Link hinzufügen",
"filter": {
"clearAll": "Alles löschen"
},
"apps": {
"count": "Appanzahl: {{ count }}"
}
},
"main": {
@@ -51,7 +55,8 @@
},
"action": {
"logs": "Logs",
"reboot": "Neustarten"
"reboot": "Neustarten",
"showLogs": "Zeige Logs"
},
"pagination": {
"perPageSelector": "Zeige {{ n }} pro Seite",
@@ -79,7 +84,8 @@
"justNow": "gerade eben",
"yeserday": "Gestern",
"minutesAgo": "vor {{ m }} Minuten",
"hoursAgo": "vor {{ h }} Stunden"
"hoursAgo": "vor {{ h }} Stunden",
"never": "Nie"
},
"disableAction": "Deaktivieren",
"enableAction": "Aktivieren",
@@ -89,16 +95,18 @@
},
"statusDisabled": "Deaktiviert",
"loadingPlaceholder": "Laden",
"settings": "Einstellungen"
"settings": "Einstellungen",
"saveAction": "Speichern"
},
"network": {
"title": "Netzwerk",
"dyndns": {
"title": "Dynamischer DNS",
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft."
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft.",
"showLogsAction": "Zeige Logs"
},
"configureIp": {
"title": "IP-Anbieter konfigurieren",
"title": "IPv4-Anbieter konfigurieren",
"providerGenericDescription": "Die öffentliche IP-Adresse des Servers wird automatisch erkannt."
},
"firewall": {
@@ -112,12 +120,12 @@
"blocklist": "{{ blockCount }} IP(s) sind gesperrt"
},
"ip": {
"description": "Cloudron verwendet diese IP-Adresse beim Einrichten von DNS-Einträgen.",
"description": "Cloudron verwendet diese IPv4-Adresse beim Einrichten von DNS A Einträgen.",
"provider": "Anbieter",
"interface": "Name der Netzwerkschnittstelle",
"configure": "Konfigurieren",
"interfaceDescription": "Verfügbare Netzwerkgeräte auf dem Server anzeigen mit:",
"title": "IP-Adresse",
"title": "IPv4",
"detected": "ermittelt",
"address": "IP Adresse"
},
@@ -131,7 +139,13 @@
},
"ipv4": {
"address": "IPv4 Adresse"
}
},
"trustedIps": {
"description": "HTTP header, von übereinstimmenden IP-Adressen, wird vertraut",
"summary": "{{ trustCount }} IPs vertrauen",
"title": "Konfiguriere vertrauenswürdige IPs"
},
"trustedIpRanges": "Vertrauenswürdige IPs & IP-Bereichen "
},
"settings": {
"title": "Einstellungen",
@@ -167,7 +181,7 @@
"description": "Ein Cloudron.io-Konto wird für den Zugriff auf den App-Store und die Verwaltung des Abonnements verwendet.",
"subscriptionSetupAction": "Abonnement einrichten",
"cloudronId": "Cloudron-ID",
"subscriptionChangeAction": "Abonnement ändern",
"subscriptionChangeAction": "Abonnement verwalten",
"setupAction": "Konto einrichten",
"subscription": "Abonnement-Typ",
"subscriptionReactivateAction": "Abonnement reaktivieren",
@@ -216,7 +230,7 @@
"configureAction": "Einrichten",
"syncAction": "Synchronisieren",
"showLogsAction": "Zeige Logs",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden auf der Cloudron-Instanz",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden",
"auth": "Authentifizierung",
"groupnameField": "Gruppennamen Feld",
"groupFilter": "Gruppenfilter",
@@ -230,10 +244,11 @@
"provider": "Anbieter",
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
"subscriptionRequiredAction": "Abonnenement jetzt abschließen",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Passwörter beim Anmelden werden immer durch den externen Server validiert. Die Synchronisierung läuft nicht automatisch, sondern muss manuell gestartet werden.",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Die Synchronisierung läuft automatisch, kann aber auch manuell gestartet werden.",
"title": "Verbinde ein externes Verzeichnis",
"providerOther": "Sonstige",
"providerDisabled": "Deaktiviert"
"providerDisabled": "Deaktiviert",
"disableWarning": "Die Authentifizierungsmethode von allen Usern wird auf die lokale Datenbank zurückgesetzt."
},
"settings": {
"saveAction": "Speichern",
@@ -342,7 +357,9 @@
"username": "Username",
"fullName": "Vollständiger Name",
"fallbackEmailPlaceholder": "Optional. Falls nicht gesetzt wird die Primäre E-Mail benutzt",
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden"
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden",
"external2FA": "2FA Einstellungen werden von der externen Authentifikationsmethode verwaltet",
"ldapGroups": "LDAP Gruppen"
},
"addUserDialog": {
"addUserAction": "User hinzufügen",
@@ -386,12 +403,13 @@
},
"description": "Cloudron kann als zentraler Benutzerverzeichnis-Server für externe Anwendungen fungieren.",
"ipRestriction": {
"description": "Der Verzeichnisserver kann auf bestimmte IPs oder Bereiche beschränkt werden.",
"description": "Der Verzeichnisserver muss auf bestimmte IPs oder Bereiche beschränkt werden. Zeilen, die mit <code>#</code> beginnen werden als Kommentare gewertet.",
"label": "Zugriff beschränken",
"placeholder": "Zeilen separierte IP Adresse oder Subnetz"
},
"enabled": "Aktiviert",
"title": "Verzeichnis Server"
"title": "Verzeichnis Server",
"cloudflarePortWarning": "Cloudflare Proxying für die Dashboarddomäne muss deaktiviert sein um den LDAP Server zu erreichen"
},
"invitationNotification": {
"title": "Einladungslink versendet",
@@ -500,7 +518,10 @@
"changeEmail": {
"errorEmailRequired": "Eine gültige E-Mail-Adresse ist erforderlich",
"errorEmailInvalid": "Die E-Mail-Adresse ist nicht gültig",
"title": "Primäre E-Mail-Adresse ändern"
"title": "Primäre E-Mail-Adresse ändern",
"email": "Neue E-Mail-Adresse",
"password": "Passwort zur Bestätigung",
"errorWrongPassword": "Falsches Passwort"
},
"loginTokens": {
"logoutAll": "Von allen abmelden",
@@ -529,14 +550,15 @@
},
"changeBackgroundImage": {
"title": "Hintergrundbild setzen"
}
},
"enable2FANotAvailable": "Für externe User nicht verfügbar"
},
"emails": {
"title": "E-Mail",
"settings": {
"spamFilter": "Spamfilter",
"maxMailSize": "Maximalgröße einer E-Mail",
"location": "Standort des Mail-Servers",
"location": "Domäne des Mail-Servers",
"info": "Die Einstellungen sind global und werden bei allen Domains verwendet.",
"title": "Einstellungen",
"spamFilterOverview": "{{ blacklistCount }} Adressen sind auf der Blockliste.",
@@ -547,7 +569,8 @@
"solrNotRunning": "Inaktiv",
"solrRunning": "Aktiv",
"aclOverview": "{{ dnsblZonesCount }} DNSBL Zonen",
"acl": "Postfachberechtigungen"
"acl": "Postfachberechtigungen",
"virtualAllMail": "\"All Mail\" Ordner"
},
"domains": {
"testEmailTooltip": "Test E-Mail senden",
@@ -596,7 +619,7 @@
},
"changeDomainDialog": {
"locationPlaceholder": "Leer lassen, um die Haupt-Domäne zu verwenden",
"description": "Cloudron nimmt die notwendigen DNS-Änderungen in allen Domänen vor und startet den Mail-Server neu. Desktop & Mobile E-Mail-Clients müssen neu konfiguriert werden, um diese neue Adresse als IMAP- und SMTP-Server zu verwenden.",
"description": "Dies zieht den E-Mail Server auf die neue Domäne um.",
"location": "Adresse",
"title": "E-Mail-Server Standort ändern",
"manualInfo": "Manuell einen A-Eintrag für {{ Domain }} zur öffentlichen IP dieses Cloudrons hinzufügen"
@@ -647,6 +670,10 @@
},
"action": {
"queue": "Warteschlange"
},
"changeVirtualAllMailDialog": {
"description": "Der \"All Mail\" Ordner ist ein einziger Ordner, welcher alle E-Mails des Posteingangs beinhaltet. Dieser Ordner unterstützt mit E-Mail Anwendungen, welche keine rekursive Suche anbieten.",
"title": "\"All Mail\" Ordner"
}
},
"support": {
@@ -669,7 +696,8 @@
"report": "Meldung",
"subscriptionRequiredDescription": "Antworten auf die häufigsten Fragen sind in der <a href=\"{{ supportViewLink }}\" target=\"_blank\">Dokumentation</a> verfügbar. Unser <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a> bietet einen Platz in die Community einzusteigen und sich auszutauschen.",
"emailVerifyAction": "Jetzt verifizieren",
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen."
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen.",
"typeBilling": "Problem mit Rechnung"
},
"remoteSupport": {
"title": "Fernwartung",
@@ -678,6 +706,10 @@
"subscriptionRequired": "Fernwartung ist nur im Abo verfügbar.",
"description": "Diese Option aktivieren, um Mitarbeitenden aus dem Support zu erlauben, sich über SSH mit diesem Server zu verbinden.",
"disableAction": "Zugang zur SSH-Unterstützung deaktivieren"
},
"help": {
"description": "Bitte die folgenden Resourcen für Hilfe und Support:\n* [Cloudron Forum]({{ forumLink }}) - Bitte die Support und App spezifischen Kategorien nutzen .\n* [Cloudron Doku & Wissensdatenbank]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n",
"title": "Hilfe"
}
},
"eventlog": {
@@ -743,12 +775,18 @@
"cloudflareDefaultProxyStatus": "Proxying für neue DNS-Einträge aktivieren",
"porkbunSecretapikey": "Geheimer API-Schlüssel",
"porkbunApikey": "API-Schlüssel",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Access Token",
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"changeDashboardDomain": {
"title": "Die Dashboard-Domäne ändern",
"showLogsAction": "Logfiles anzeigen",
"description": "Dadurch werden das Dashboard und der E-Mail-Server in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"description": "Dadurch wird das Dashboard in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"changeAction": "Domäne ändern",
"cancelAction": "Abbrechen"
},
@@ -776,7 +814,8 @@
"tooltipWellKnown": ".well-known Pfade setzen",
"domainWellKnown": {
"title": ".well-known Pfade von {{ domain }}"
}
},
"count": "Domänenanzahl: {{ count }}"
},
"notifications": {
"title": "Benachrichtigungen",
@@ -807,7 +846,19 @@
"title": "CPU-Auslastung",
"graphSubtext": "Es werden nur Anwendungen angezeigt, die mehr als {{ threshold }} an Rechenleistung benötigen"
},
"selectPeriodLabel": "Zeitraum auswählen"
"selectPeriodLabel": "Zeitraum auswählen",
"info": {
"platformVersion": "Plattform Version",
"title": "Info",
"vendor": "Anbieter",
"product": "Produkt",
"memory": "Arbeitsspeicher",
"uptime": "Betriebszeit",
"activationTime": "Cloudron Aktivierungszeit"
},
"graphs": {
"title": "Graphen"
}
},
"backups": {
"title": "Datensicherung",
@@ -933,7 +984,8 @@
"tooltip": "Dadurch bleiben auch die Mail- und {{ appsLength }} App-Backups erhalten.",
"description": "Backup unabhängig von der Aufbewahrungsrichtlinie beibehalten"
},
"label": "Label"
"label": "Label",
"remotePath": "Remote Pfad"
}
},
"appstore": {
@@ -952,7 +1004,10 @@
"email": "E-Mail",
"description": "Dieses Konto gibt Zugriff zum App-Store und Aboverwaltung",
"titleLogin": "Bei Cloudron.io anmelden",
"titleSignUp": "Bei Cloudron.io registrieren"
"titleSignUp": "Bei Cloudron.io registrieren",
"setupWithTokenAction": "Registrieren",
"setupToken": "Setup Token",
"titleToken": "Mit Setup Token registrieren"
},
"appNotFoundDialog": {
"description": "Die Anwendung <b>{{ appId }}</b> mit der Version <b>{{ version }}</b> existiert nicht.",
@@ -1052,7 +1107,9 @@
"title": "Fußzeile"
},
"logo": "Logo",
"cloudronName": "Name der Cloudron-Instanz"
"cloudronName": "Name der Cloudron-Instanz",
"backgroundImage": "Hintergrundbild der Login-Seite",
"clearBackgroundImage": "Löschen"
},
"login": {
"password": "Passwort",
@@ -1061,7 +1118,10 @@
"2faToken": "2FA-Token (wenn aktiviert)",
"loginTo": "Anmeldung bei",
"signInAction": "Anmelden",
"resetPasswordAction": "Passwort zurücksetzen"
"resetPasswordAction": "Passwort zurücksetzen",
"loginWith": "Mit Cloudron anmelden",
"errorIncorrect2FAToken": "2FA Token ist ungültig",
"errorInternal": "Interner Fehler, später nochmals versuchen"
},
"welcomeEmail": {
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
@@ -1170,7 +1230,7 @@
"enableEmailDialog": {
"description": "Dies wird Cloudron so konfigurieren, dass E-Mails für <b>{{ domain }}</b> empfangen werden. Die Dokumentation zum Öffnen der <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">erforderlichen Ports</a> für Cloudron E-Mail lesen.",
"noProviderInfo": "Es ist kein DNS-Anbieter eingerichtet. Die in der Registerkarte Status aufgeführten DNS-Einträge müssen manuell eingerichtet werden.",
"cloudflareInfo": "Die Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"cloudflareInfo": "Die E-Mail Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"enableAction": "Aktivieren",
"title": "E-Mail für {{ domain }} aktivieren?",
"setupDnsCheckbox": "DNS-Einträge für E-Mail jetzt einrichten",
@@ -1318,7 +1378,8 @@
"renameDialog": {
"newName": "Neuer Name",
"title": "{{ fileName }} umbennen",
"rename": "Umbenennen"
"rename": "Umbenennen",
"reallyOverwrite": "Eine Datei mit diesem Namen existiert bereits. Diese Datei überschreiben?"
},
"extractDialog": {
"title": "Extrahieren von {{ fileName }}",
@@ -1380,7 +1441,19 @@
},
"status": {
"restartingApp": "Die Anwendung wird neugestartet"
}
},
"uploader": {
"uploading": "Hochladen",
"exitWarning": "Aktuell werden noch Dateien hochgeladen. Wirklich schließen?"
},
"textEditor": {
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"save": "Speichern"
},
"extractionInProgress": "Entpacken läuft",
"pasteInProgress": "Einfügen läuft",
"deleteInProgress": "Löschen läuft"
},
"passwordReset": {
"usernameOrEmail": "Username oder E-Mail-Adresse",
@@ -1456,14 +1529,14 @@
"logsActionTooltip": "Logfiles",
"resources": {
"cpu": {
"setAction": "Festlegen",
"title": "CPU-Freigabe",
"description": "Prozent der CPU-Zeit, wenn das System unter hoher Last steht."
"setAction": "Skalieren",
"title": "CPU Limit",
"description": "Maximale CPU Prozente, die dieser App zur Verfügung stehen"
},
"memory": {
"resizeAction": "Größe ändern",
"title": "Speicherlimit",
"description": "Cloudron weist 50% dieses Wertes als RAM und 50% als Swap zu.",
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht",
"error": "Speicherlimit nicht einstellbar. Weniger versuchen."
}
},
@@ -1530,7 +1603,7 @@
},
"uninstall": {
"backupWarning": "Anwendungs-Backups werden nicht entfernt und auf der Grundlage der Backup-Richtlinie bereinigt. Diese Anwendung kann aus einem bestehenden App-Backup mit den folgenden <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">Schritten</a> wiederhergestellt werden.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle zugehörigen Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"title": "Deinstallieren",
"uninstallAction": "Deinstallieren"
}
@@ -1546,12 +1619,12 @@
},
"updates": {
"auto": {
"enableAction": "Automatische Aktualisierungen aktivieren",
"enableAction": "Aktivieren",
"disabled": "Die automatische Aktualisierung ist deaktiviert.",
"enabled": "Die automatische Aktualisierung ist aktiviert.",
"title": "Automatische Aktualisierungen",
"description": "Cloudron fragt regelmäßig den App-Store nach Aktualisierungen ab. Wenn automatisches Aktualisieren deaktiviert ist, bitte sicherstellen, dass manuell nach Aktualisierungen gesucht wird.",
"disableAction": "Automatische Aktualisierungen deaktivieren"
"disableAction": "Deaktivieren"
},
"info": {
"updateAvailableAction": "Aktualisierung verfügbar",
@@ -1562,7 +1635,8 @@
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"packageVersion": "Paket-Version",
"repository": "Paket-Repository"
"repository": "Paket-Repository",
"installedAt": "Installationszeitpunkt"
},
"noUpdates": "Keine neuen Updates verfügbar"
},
@@ -1609,7 +1683,8 @@
"dataDirPlaceholder": "Leer lassen, um Systemvorgabe zu verwenden",
"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.",
"moveAction": "Daten verschieben",
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }})."
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }}).",
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
},
"mounts": {
"title": "Mounts",
@@ -1759,12 +1834,31 @@
},
"addApplinkDialog": {
"title": "Link zur externen Anwendung hinzufügen"
}
},
"redis": {
"disable": "Redis deaktivieren",
"title": "Redis Konfiguration",
"enable": "Die App mit Redis vorkonfigurieren"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrator Notizen"
}
},
"turn": {
"enable": "App für den internen TURN Server konfigurieren",
"disable": "TURN Server dieser App nicht automatisch konfigurieren.",
"title": "TURN Einstellungen"
},
"servicesTabTitle": "Dienste"
},
"logs": {
"download": "Vollständige Logfiles herunterladen",
"title": "Logfiles",
"clear": "Anzeige löschen"
"clear": "Anzeige löschen",
"notFoundError": "Task oder App existiert nicht",
"logsGoneError": "Logdatei(n) nicht gefunden"
},
"lang": {
"en": "Englisch",
@@ -1778,7 +1872,8 @@
"es": "Spanisch",
"ru": "Russisch",
"pt": "Portugiesisch",
"da": "Dänisch"
"da": "Dänisch",
"id": "Indonesian"
},
"volumes": {
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
@@ -1815,7 +1910,11 @@
"mountStatus": "Einhängestatus",
"localDirectory": "Lokales Verzeichnis",
"type": "Typ",
"remountActionTooltip": "Datenträger neu einhängen"
"remountActionTooltip": "Datenträger neu einhängen",
"editVolumeDialog": {
"title": "Datenträger {{ name }} konfigurieren"
},
"editActionTooltip": "Datenträger konfigurieren"
},
"lang.ja": "Japanisch",
"newLoginEmail": {
@@ -1869,5 +1968,6 @@
"newClient": "Neuer Client",
"empty": "Noch keine Clienten erstellt"
}
}
},
"automation": "Automatisierung"
}
+39 -22
View File
@@ -30,6 +30,9 @@
"addApplinkAction": "Add App Link",
"filter": {
"clearAll": "Clear All"
},
"apps": {
"count": "Total apps: {{ count }}"
}
},
"main": {
@@ -81,7 +84,8 @@
"justNow": "just now",
"yeserday": "Yesterday",
"minutesAgo": "{{ m }} minutes ago",
"hoursAgo": "{{ h }} hours ago"
"hoursAgo": "{{ h }} hours ago",
"never": "Never"
},
"navbar": {
"users": "Users"
@@ -629,7 +633,7 @@
},
"check": {
"noop": "Cloudron backups are disabled. Please ensure this server is backed up using alternate means. See https://docs.cloudron.io/backups/#storage-providers for more information.",
"sameDisk": "Cloudron backups are currently on the same disk as the Cloudron server instance. This is dangerous and can lead to complete data loss if the disk fails. See https://docs.cloudron.io/backups/#storage-providers for storing backups in an external location."
"sameDisk": "Backups are currently on the same disk as Cloudron itself. If the disk fills up with these backups, Cloudron will not function. A disk failure can also lead to complete data loss. See https://docs.cloudron.io/backups/#storage-providers for storing backups in an external location."
},
"backupEdit": {
"title": "Edit Backup",
@@ -653,7 +657,9 @@
},
"changeLogo": {
"title": "Choose Cloudron Avatar"
}
},
"backgroundImage": "Login page background image",
"clearBackgroundImage": "Clear"
},
"emails": {
"title": "Email",
@@ -781,8 +787,8 @@
"network": {
"title": "Network",
"ip": {
"title": "IP Address",
"description": "Cloudron uses this IP address when setting up DNS records.",
"title": "IPv4",
"description": "Cloudron uses this IPv4 address to setup DNS A records.",
"provider": "Provider",
"interface": "Network Interface Name",
"configure": "Configure",
@@ -806,7 +812,7 @@
"showLogsAction": "Show Logs"
},
"configureIp": {
"title": "Configure IP Provider",
"title": "Configure IPv4 Provider",
"providerGenericDescription": "The Public IP address of the server will be automatically detected."
},
"ipv4": {
@@ -864,7 +870,7 @@
},
"timezone": {
"title": "Time Zone",
"description": "The current timezone setting is <b>{{ timeZone }}</b>.\nThis setting is used for scheduling backup and update tasks."
"description": "The current timezone setting is <b>{{ timeZone }}</b>. This setting is used for scheduling backup and update tasks. Timestamps in the UI are always displayed using the browser's timezone."
},
"updates": {
"title": "Updates",
@@ -1078,7 +1084,8 @@
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
"ovhAppSecret": "Application Secret",
"deSecToken": "deSEC Token"
},
"removeDialog": {
"title": "Really remove {{ domain }}?",
@@ -1136,7 +1143,8 @@
"copy": "Copy",
"clear": "Clear",
"pasteInfo": "For Paste use Ctrl+v"
}
},
"uploadTo": "Upload to {{ path }}"
},
"filemanager": {
"title": "File Manager",
@@ -1358,7 +1366,7 @@
"title": "Enable Email for {{ domain }}?",
"description": "This will configure Cloudron to receive emails for <b>{{ domain }}</b>. See the documentation for opening up the <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">required ports</a> for Cloudron Email.",
"noProviderInfo": "No DNS provider is set up. The DNS records listed in the Status tab have to be set up manually.",
"cloudflareInfo": "The domain <code>{{ adminDomain }}</code> is managed by Cloudflare. Please verify that Cloudflare proxying is disabled for <code>{{ mailFqdn }}</code> and set to <code>DNS only</code>. This is required because Cloudflare does not proxy email.",
"cloudflareInfo": "The mail server's domain <code>{{ adminDomain }}</code> is managed by Cloudflare. Please verify that Cloudflare proxying is disabled for <code>{{ mailFqdn }}</code> and set to <code>DNS only</code>. This is required because Cloudflare does not proxy email.",
"setupDnsCheckbox": "Set up Mail DNS records now",
"setupDnsInfo": "Use this option to automatically set up Email related DNS records. Leaving this option unchecked is useful for creating mail boxes and <a href=\"{{ importEmailDocsLink }}\">importing email</a> before going live.",
"enableAction": "Enable"
@@ -1494,14 +1502,14 @@
"resources": {
"memory": {
"title": "Memory Limit",
"description": "Cloudron allocates 50% of this value as RAM and 50% as swap.",
"description": "Maximum memory app can use",
"error": "Unable to set memory limit, try less.",
"resizeAction": "Resize"
},
"cpu": {
"setAction": "Set",
"title": "CPU Shares",
"description": "Percent of CPU time when system is under heavy load."
"setAction": "Scale",
"title": "CPU Limit",
"description": "Maximum percent of CPU app can use"
}
},
"storage": {
@@ -1585,17 +1593,18 @@
"packageVersion": "Package Version",
"lastUpdated": "Last Updated",
"checkForUpdatesAction": "Check for Updates",
"customAppUpdateInfo": "Updates are not available for custom apps",
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
"updateAvailableAction": "Update Available",
"repository": "Package Repository"
"repository": "Package Repository",
"installedAt": "Installed At"
},
"auto": {
"title": "Automatic Updates",
"description": "Cloudron periodically checks the App Store for updates. If you disable automatic updates, be sure to manually apply the updates.",
"description": "Cloudron periodically checks the <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> for updates.",
"enabled": "Automatic Updates is currently enabled.",
"disabled": "Automatic Updates is currently disabled.",
"disableAction": "Disable Automatic Updates",
"enableAction": "Enable Automatic Updates"
"disableAction": "Disable Auto-update",
"enableAction": "Enable Auto-update"
},
"noUpdates": "No new updates available"
},
@@ -1649,7 +1658,7 @@
},
"uninstall": {
"title": "Uninstall",
"description": "This will uninstall the app immediately and remove all its data. The site will be inaccessible.",
"description": "This will uninstall the app immediately and remove the app's data. The site will be inaccessible.",
"backupWarning": "App backups are not removed and will be cleaned up based on the backup policy. You can resurrect this app from an existing app backup using the following <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">instructions</a>.",
"uninstallAction": "Uninstall"
}
@@ -1766,6 +1775,12 @@
"title": "Redis Configuration",
"enable": "Configure the app to use Redis",
"disable": "Disable Redis"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Admin Notes"
}
}
},
"login": {
@@ -1777,7 +1792,8 @@
"signInAction": "Sign in",
"resetPasswordAction": "Reset password",
"errorIncorrect2FAToken": "2FA token is invalid",
"errorInternal": "Internal error, try again later"
"errorInternal": "Internal error, try again later",
"loginWith": "Login with Cloudron"
},
"passwordReset": {
"title": "Password reset",
@@ -1858,7 +1874,8 @@
"es": "Spanish",
"ru": "Russian",
"pt": "Portuguese",
"da": "Danish"
"da": "Danish",
"id": "Indonesian"
},
"volumes": {
"title": "Volumes",
+168 -34
View File
@@ -156,7 +156,8 @@
"description": "Cloudron va importer les utilisateurs et les groupes depuis un annuaire LDAP externe ou Active Directory. La vérification du mot de passe pour l'authentification de ces utilisateurs se fait via le serveur externe. La synchronisation ne s'exécute pas automatiquement, elle doit être lancée manuellement.",
"subscriptionRequiredAction": "Paramétrer mon abonnement maintenant",
"providerOther": "Autre",
"providerDisabled": "Désactivé"
"providerDisabled": "Désactivé",
"disableWarning": "La source d'authentification de tous les utilisateurs existants sera réinitialisée pour utiliser la base de données locale."
},
"role": {
"usermanager": "Gestionnaire",
@@ -188,7 +189,9 @@
"errorInvalidEmail": "Cette adresse email est invalide",
"usernamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la première connexion",
"fallbackEmailPlaceholder": "Optionnel. Si laissé vide, ce sera l'adresse email principale qui sera utilisée",
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte"
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte",
"external2FA": "La configuration multi-facteur est gérée par une source externe",
"ldapGroups": "Groupes LDAP"
},
"group": {
"errorNameRequired": "Un nom est nécessaire",
@@ -271,7 +274,7 @@
},
"exposedLdap": {
"secret": {
"label": "Mot de passe de liaison",
"label": "Mot de passe Bind",
"description": "Toutes les requêtes LDAP doivent être authentifiées avec ce secret et le DN utilisateur <i>{{ userDN }}</i>",
"url": "URL du serveur"
},
@@ -282,7 +285,8 @@
"placeholder": "Adresse IP séparée par ligne ou sous-réseau",
"label": "Accès restreint"
},
"title": "Serveur d'annuaire"
"title": "Serveur d'annuaire",
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP"
},
"userImportDialog": {
"title": "Importer des utilisateurs",
@@ -345,7 +349,10 @@
"changeEmail": {
"errorEmailInvalid": "Cette adresse email est invalide",
"title": "Modifier l'adresse email principale",
"errorEmailRequired": "Une adresse email valide est nécessaire"
"errorEmailRequired": "Une adresse email valide est nécessaire",
"email": "Nouvelle adresse e-mail",
"password": "Mot de passe pour confirmation",
"errorWrongPassword": "Mauvais mot de passe"
},
"createAppPassword": {
"copyNow": "Veillez à copier le mot de passe maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
@@ -416,7 +423,8 @@
},
"changeBackgroundImage": {
"title": "Définir l'image d'arrière-plan"
}
},
"enable2FANotAvailable": "Non disponible pour les utilisateurs provenant d'une source d'authentification externe"
},
"backups": {
"title": "Sauvegardes",
@@ -500,7 +508,7 @@
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
"chown": "Le système de fichiers distant prend en charge chown",
"encryptedFilenames": "Crypter les noms de fichiers",
"encryptFilenames": "Fichiers Cryptés"
"encryptFilenames": "Chiffré les nom de fichiers"
},
"backupDetails": {
"title": "Informations sur la sauvegarde",
@@ -543,7 +551,7 @@
"description": "Sauvegarde persistante quelle que soit la politique de rétention",
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
},
"remotePath": "Répertoire Distant"
"remotePath": "Chemin d'accès à distance"
}
},
"emails": {
@@ -553,7 +561,7 @@
"location": "Emplacement",
"title": "Changer l'emplacement du serveur de messagerie",
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu",
"description": "Cloudron effectuera les modifications DNS nécessaires pour l'ensemble des domaines et redémarrera le serveur de messagerie. Les clients de messagerie sur ordinateur et sur mobile doivent être reconfigurés pour que ce nouvel emplacement soit utilisé comme serveur IMAP et SMTP."
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi."
},
"eventlog": {
"details": "Détails",
@@ -663,6 +671,10 @@
},
"action": {
"queue": "File d'attente"
},
"changeVirtualAllMailDialog": {
"title": "Dossier \"Tout les Emails\"",
"description": "Le dossier \"Tout les E-mails\" est un dossier contenant tout les e-mails de votre boite de réception. Ce dossier peut être utile pour les clients e-mails ne supportant pas les dossiers imbriqués."
}
},
"network": {
@@ -679,7 +691,8 @@
},
"dyndns": {
"title": "DNS dynamique",
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique."
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique.",
"showLogsAction": "Afficher les journaux"
},
"ip": {
"configure": "Paramétrer",
@@ -705,7 +718,13 @@
"address": "Adresse IPv6",
"title": "IPv6",
"description": "Cloudron utilise cette adresse IPv6 pour configurer les enregistrements DNS AAAA.\n"
}
},
"trustedIps": {
"description": "Les en-têtes HTTP provenant d'adresses IP correspondantes seront considérés comme sûrs",
"title": "Configurer les adresses IP de Confiance",
"summary": "{{ trustCount }} adresses IP de confiance"
},
"trustedIpRanges": "Adresses et plages d'IP de confiance. "
},
"settings": {
"title": "Paramètres",
@@ -809,7 +828,12 @@
"subscriptionRequiredDescription": "Vous devriez trouver votre réponse dans notre <a href=\"{{ supportViewLink }}\" target=\"_blank\">documentation</a>, vous pouvez également poser votre question sur le <a href=\"{{ forumLink }}\" target=\"_blank\">forum</a>.",
"title": "Ticket",
"emailVerifyAction": "Confirmer maintenant",
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident."
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident.",
"typeBilling": "Problème de facturation"
},
"help": {
"description": "Veuillez utiliser les ressources suivantes pour obtenir de l'aide\n* [Forum Cloudron]({{ forumLink }}) - Veuillez utiliser les catégories d'assistance et d'applications spécifiques pour vos questions.\n* [Documentation et base de connaissances de Cloudron]({{ docsLink }})\n* [Packaging d'applications personnalisées et API]({{ packagingLink }})\n",
"title": "Aide"
}
},
"notifications": {
@@ -917,7 +941,8 @@
"packageVersion": "Version du package",
"appId": "ID de l'application",
"description": "Nom et version de l'application",
"title": "Informations sur l'application"
"title": "Informations sur l'application",
"repository": "Dépot de paquets"
},
"auto": {
"title": "Mises à jour automatiques",
@@ -926,7 +951,8 @@
"disabled": "Les mises à jour automatiques sont actuellement désactivées.",
"enabled": "Les mises à jour automatiques sont actuellement activées.",
"description": "Cloudron vérifie régulièrement les mises à jour disponibles dans l'App Store. Si vous désactivez les mises à jour automatiques, veillez à les faire manuellement."
}
},
"noUpdates": "Aucune nouvelle mise à jour disponible"
},
"backupsTabTitle": "Sauvegardes",
"storage": {
@@ -936,13 +962,20 @@
"noMounts": "Aucun volume n'est monté.",
"volume": "Volume",
"readOnly": "En lecture seule",
"title": "Montages"
"title": "Montages",
"permissions": {
"label": "Permissions",
"readOnly": "Lecture seule",
"readWrite": "Lecture et écriture"
}
},
"appdata": {
"moveAction": "Déplacer les données",
"dataDirPlaceholder": "Laisser vide pour utiliser la plateforme par défaut",
"description": "Si le serveur manque d'espace disque, utilisez-le pour déplacer les données de l'application vers un <a href=\"/#/volumes\">volume</a>. Toutes les données ici font partie de la sauvegarde de l'application.",
"title": "Données de l'application"
"title": "Données de l'application",
"diskUsage": "L'application utilise actuellement {{ size }} de stockage (en date du {{ date }}).",
"mountTypeWarning": "Le système de fichiers de destination doit prendre en charge les autorisations et la propriété des fichiers pour que le transfert fonctionne"
}
},
"security": {
@@ -955,7 +988,8 @@
"disableIndexingAction": "Désactiver l'indexation",
"txtPlaceholder": "Laisser vide pour autoriser les robots à indexer cette application",
"title": "Robots.txt"
}
},
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
},
"updateDialog": {
"updateAction": "Mettre à jour",
@@ -1049,7 +1083,8 @@
"uploadAction": "Charger le fichier de configuration de la sauvegarde",
"description": "Toutes les données créées depuis la dernière sauvegarde connue seront définitivement perdues. Il est fortement recommandé de sauvegarder les données actuelles avant de lancer un import.",
"title": "Importer la sauvegarde",
"importAction": "Importer"
"importAction": "Importer",
"remotePath": "Chemin de la sauvegarde"
},
"repairDialog": {
"fromBackup": "Restaurer depuis la sauvegarde :",
@@ -1120,7 +1155,8 @@
"time": "Créée le",
"packageVersion": "Version du package",
"description": "Les sauvegardes sont des instantanés complets de l'application. Vous pouvez utiliser les sauvegardes pour restaurer ou cloner l'application.",
"title": "Sauvegardes"
"title": "Sauvegardes",
"downloadBackupTooltip": "Télécharger la sauvegarde"
}
},
"graphs": {
@@ -1132,7 +1168,9 @@
"12h": "12 heures",
"6h": "6 heures"
},
"diskTitle": "Utilisation du disque"
"diskTitle": "Utilisation du disque",
"diskIOTotal": "total: lecture {{ read }} / écriture {{ write }}",
"networkIOTotal": "total: entrant {{ inbound }} / sortant {{ outbound }}"
},
"resources": {
"memory": {
@@ -1221,12 +1259,25 @@
"label": "Étiquette",
"clearIconAction": "Effacer Icône",
"clearIconDescription": "Cela récupérera le favicon de l'application."
},
"servicesTabTitle": "Services",
"turn": {
"enable": "Configurer l'application pour utiliser le serveur TURN intégré",
"disable": "Ne pas configurer les paramètres TURN de l'application. Les paramètres TURN de l'application sont laissés à leur valeurs par défaut. Vous pouvez les configurer à l'intérieur de l'application.",
"title": "Configuration de TURN"
},
"redis": {
"title": "Configuration de Redis",
"enable": "Configurer l'application pour utiliser Redis",
"disable": "Désactiver Redis"
}
},
"logs": {
"title": "Journaux",
"download": "Télécharger l'ensemble des journaux",
"clear": "Nettoyer"
"clear": "Nettoyer",
"notFoundError": "Aucune tâche ou application de ce type",
"logsGoneError": "Fichier(s) journal(s) introuvable(s)"
},
"volumes": {
"name": "Nom",
@@ -1263,7 +1314,11 @@
"title": "Mettre à jour le volume {{ volume }}"
},
"mountStatus": "Statut du montage",
"type": "Type"
"type": "Type",
"editVolumeDialog": {
"title": "Modifier le volume {{ name }}"
},
"editActionTooltip": "Modifier le volume"
},
"lang": {
"en": "Anglais",
@@ -1277,7 +1332,8 @@
"zh_Hans": "Chinois (Simplifié)",
"es": "Espagnol",
"ru": "Russe",
"pt": "Portugais"
"pt": "Portugais",
"da": "Danois"
},
"email": {
"mailboxboxDialog": {
@@ -1522,10 +1578,19 @@
"vultrToken": "Token Vultr",
"wellKnownDescription": "Les valeurs seront utilisées par Cloudron pour répondre aux URL <code>/.well-known/</code>. Notez qu'une application doit être disponible sur le domaine nu <code>{{ domaine }}</code> pour que cela fonctionne. Consultez la <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> pour plus d'informations.",
"hetznerToken": "Token Hetzner",
"jitsiHostname": "Emplacement de Jitsi"
"jitsiHostname": "Emplacement de Jitsi",
"cloudflareDefaultProxyStatus": "Activer le proxy pour les nouveaux enregistrements DNS",
"porkbunApikey": "Clé API",
"porkbunSecretapikey": "Clé API secrète",
"dnsimpleAccessToken": "Jeton d'accès",
"ovhEndpoint": "Point de terminaison",
"bunnyAccessKey": "Bunny Access Key",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"changeDashboardDomain": {
"description": "Cette action entraînera le déplacement du tableau de bord et du serveur de messagerie vers le sous-domaine <code>my</code> du domaine sélectionné.",
"description": "Cette action entraînera le déplacement du tableau de bord vers le sous-domaine <code>my</code> du domaine sélectionné.",
"showLogsAction": "Afficher les journaux",
"cancelAction": "Annuler",
"changeAction": "Changer le domaine",
@@ -1551,7 +1616,8 @@
"domainWellKnown": {
"title": "Emplacements Well-Known de {{ domain }}"
},
"tooltipWellKnown": "Définir des emplacements Well-Known"
"tooltipWellKnown": "Définir des emplacements Well-Known",
"count": "Nombre de domaines: {{ count }}"
},
"branding": {
"footer": {
@@ -1632,7 +1698,8 @@
"download": "Télécharger",
"extract": "Extraire ici",
"chown": "Modifier la propriété",
"rename": "Renommer"
"rename": "Renommer",
"open": "Ouvrir"
},
"symlink": "Symlink vers {{ target }}",
"empty": "Aucun fichier",
@@ -1678,7 +1745,8 @@
"renameDialog": {
"rename": "Renommer",
"newName": "Nouveau nom",
"title": "Renommer {{ fileName }}"
"title": "Renommer {{ fileName }}",
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant?"
},
"newFileDialog": {
"create": "Créer",
@@ -1691,7 +1759,19 @@
"removeDialog": {
"reallyDelete": "Voulez-vous vraiment supprimer ces fichiers ?"
},
"title": "Gestionnaire de fichiers"
"title": "Gestionnaire de fichiers",
"uploader": {
"uploading": "Téléversement",
"exitWarning": "Téléversement toujours en cours. Voulez-vous vraiment fermer cette page?"
},
"deleteInProgress": "Suppression en cours",
"textEditor": {
"undo": "Annuler",
"redo": "Refaire",
"save": "Enregistrer"
},
"extractionInProgress": "Décompression en cours",
"pasteInProgress": "Collage en cours"
},
"terminal": {
"contextmenu": {
@@ -1732,7 +1812,8 @@
"selectPeriodLabel": "Période sélectionnée",
"cpuUsage": {
"graphTitle": "Pourcentage",
"title": "Utilisation du microprocesseur"
"title": "Utilisation du microprocesseur",
"graphSubtext": "Seules les applications utilisant plus de {{ threshold }} de processeur sont affichées"
},
"systemMemory": {
"graphSubtext": "Seules les applications utilisant plus de 1GB de mémoire sont affichées",
@@ -1746,9 +1827,22 @@
"title": "Utilisation du disque",
"usedInfo": "{{ used }} utilisé de {{ size }}",
"uninstalledApp": "Désinstaller App",
"diskSpeed": "Vitesse : {{ speed }} MB/sec"
"diskSpeed": "Vitesse : {{ speed }} MB/sec",
"volumeContent": "Ce disque est le volume <code>{{ name }}</code>"
},
"title": "Info système"
"title": "Info système",
"info": {
"platformVersion": "Version de la Plate-forme",
"vendor": "Vendeur",
"product": "Produit",
"memory": "Mémoire",
"uptime": "Durée de fonctionnement",
"activationTime": "Heure de création de Cloudron",
"title": "Informations"
},
"graphs": {
"title": "Graphiques"
}
},
"services": {
"refresh": "Rafraîchir",
@@ -1803,7 +1897,9 @@
"password": "Mot de passe",
"username": "Nom d'utilisateur",
"errorIncorrectCredentials": "Nom d'utilisateur ou mot de passe incorrect",
"loginTo": "Se connecter à"
"loginTo": "Se connecter à",
"errorIncorrect2FAToken": "Le jeton 2FA n'est pas valide",
"errorInternal": "Erreur interne, réessayer ultérieurement"
},
"newLoginEmail": {
"salutation": "Bonjour <%= user %>,",
@@ -1819,5 +1915,43 @@
"mounts": {
"description": "Les applications peuvent accéder aux <a href=\"/#/volumes\">volumes</a> montés via le répertoire <code>/media/{volume name}</code>. Ces données ne sont pas incluses dans la sauvegarde de l'application."
}
}
},
"oidc": {
"client": {
"signingAlgorithm": "Algorithme de signature",
"name": "Nom",
"id": "ID du client",
"secret": "Secret du client",
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)",
"logoutRedirectUri": "Url de retour après déconnexion (facultatif)"
},
"description": "Cloudron peut agir en tant que fournisseur OpenID Connect pour les applications internes et les services externes.",
"deleteClientDialog": {
"description": "Cela déconnectera toutes les applications OpenID externes de ce Cloudron utilisant cet identifiant client.",
"title": "Supprimer définitivement le client {{ client }}?"
},
"newClientDialog": {
"title": "Ajouter un client",
"description": "Ajouter de nouveaux paramètres pour le client OpenID connect.",
"createAction": "Créer"
},
"title": "OpenID Connect Provider",
"editClientDialog": {
"title": "Modifier le client {{ client }}"
},
"env": {
"discoveryUrl": "URL de découverte",
"logoutUrl": "URL de déconnexion",
"profileEndpoint": "Point de terminaison pour le profil",
"keysEndpoint": "Point de terminaison pour les clés",
"tokenEndpoint": "Point de terminaison pour les jetons",
"authEndpoint": "Point de terminaison pour l'authentification"
},
"clients": {
"title": "Clients",
"newClient": "Nouveau client",
"empty": "Aucun client pour le moment"
}
},
"automation": "Automatisation"
}
-22
View File
@@ -1,22 +0,0 @@
{
"apps": {
"tagsFilterHeaderAll": "Semua Tag",
"adminPageActionTooltip": "Halaman Admin",
"domainsFilterHeader": "Semua Domain",
"groupsFilterHeader": "Semua Grup",
"addAppAction": "Tambah Aplikasi",
"title": "Aplikasi Saya",
"tagsFilterHeader": "Tag: {{ tags }}"
},
"main": {
"dialog": {
"no": "Tidak",
"yes": "Ya",
"delete": "Hapus",
"save": "Simpan"
},
"table": {
"date": "Tanggal"
}
}
}
+39 -21
View File
@@ -30,6 +30,9 @@
"addApplinkAction": "App link toevoegen",
"filter": {
"clearAll": "Alles verwijderen"
},
"apps": {
"count": "Totaal apps: {{ count }}"
}
},
"main": {
@@ -81,7 +84,8 @@
"justNow": "zojuist",
"yeserday": "Gisteren",
"minutesAgo": "{{ m }} minuten geleden",
"hoursAgo": "{{ h }} uur geleden"
"hoursAgo": "{{ h }} uur geleden",
"never": "Nooit"
},
"navbar": {
"users": "Gebruikers"
@@ -274,7 +278,8 @@
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"fallbackEmailPlaceholder": "Optioneel. Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding",
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron"
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron",
"ldapGroups": "LDAP Groepen"
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
@@ -628,7 +633,7 @@
},
"check": {
"noop": "Cloudron backups zijn uitgeschakeld. Zorg ervoor dat deze server op een andere manier wordt geback-upt. Kijk op https://docs.cloudron.io/backups/#storage-providers voor meer informatie.",
"sameDisk": "Cloudron backups staan momenteel op dezelfde schijf als deze Cloudron server. Dit is gevaarlijk en kan leiden tot gegevensverlies als de schijf defect raakt. Kijk op https://docs.cloudron.io/backups/#storage-providers hoe je backups op een externe locatie kan zetten."
"sameDisk": "Backups staan momenteel op dezelfde schijf als Cloudron zelf. Als de disk volloopt met deze backups zal Cloudron niet meer werken. Een defecte disk kan ook leiden tot volledig gegevensverlies. Kijk op https://docs.cloudron.io/backups/#storage-providers hoe je backups op een externe locatie kan zetten."
},
"backupEdit": {
"preserved": {
@@ -652,7 +657,9 @@
},
"changeLogo": {
"title": "Kies een Cloudron-afbeelding"
}
},
"backgroundImage": "Inlogpagina achtergrond afbeelding",
"clearBackgroundImage": "Leegmaken"
},
"emails": {
"title": "E-mail",
@@ -830,7 +837,8 @@
"ovhEndpoint": "Eindpunt",
"ovhConsumerKey": "Consumer sleutel",
"ovhAppKey": "Applicatie sleutel",
"ovhAppSecret": "Applicatie geheim"
"ovhAppSecret": "Applicatie geheim",
"deSecToken": "deSEC Token"
},
"title": "Domeinen & Certificaten",
"addDomain": "Domein toevoegen",
@@ -963,14 +971,14 @@
"resources": {
"memory": {
"title": "Geheugenlimiet",
"description": "Cloudron wijst 50% van deze waarde toe als RAM en 50% als swap.",
"description": "Maximum geheugen dat een app kan gebruiken",
"resizeAction": "Grootte wijzigen",
"error": "Kan geheugenlimiet niet instellen, probeer minder."
},
"cpu": {
"setAction": "Vastleggen",
"title": "CPU Shares",
"description": "Percentage CPU-tijd wanneer het systeem zwaar wordt belast."
"setAction": "Instellen",
"title": "CPU Limiet",
"description": "Maximum percentage CPU dat een app kan gebruiken"
}
},
"storage": {
@@ -1030,17 +1038,18 @@
"packageVersion": "Pakketversie",
"lastUpdated": "Laatst geüpdatet",
"checkForUpdatesAction": "Controleer op updates",
"customAppUpdateInfo": "Er zijn geen updates beschikbaar voor deze maatwerk app",
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
"updateAvailableAction": "Update beschikbaar",
"repository": "Pakket Opslagplaats"
"repository": "Pakket Opslagplaats",
"installedAt": "Geïnstalleerd op"
},
"auto": {
"title": "Automatische updates",
"enabled": "Automatische updates zijn momenteel ingeschakeld.",
"disabled": "Automatische updates zijn momenteel uitgeschakeld.",
"disableAction": "Automatische updates uitschakelen",
"enableAction": "Automatische updates inschakelen",
"description": "Cloudron controleert de App Store periodiek op updates. Als je dit uitschakelt zorg er dan voor dat je updates handmatig installeert."
"disableAction": "Auto-update uitschakelen",
"enableAction": "Auto-update inschakelen",
"description": "Cloudron controleert periodiek de <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> op updates."
},
"noUpdates": "Geen nieuwe updates beschikbaar"
},
@@ -1213,17 +1222,23 @@
"title": "Redis configuratie",
"enable": "Configureer de app om Redis te gebruiken",
"disable": "Redis uitschakelen"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Admin Notities"
}
}
},
"network": {
"title": "Netwerk",
"ip": {
"title": "IP Adres",
"title": "IPv4",
"provider": "Aanbieder",
"interface": "Naam netwerkinterface",
"configure": "Configureer",
"interfaceDescription": "Toon beschikbare apparaten op deze server met:",
"description": "Cloudron gebruikt dit IP adres tijdens het instellen van DNS records.",
"description": "Cloudron gebruikt dit IPv4 adres om de DNS records in te stellen.",
"detected": "gedetecteerd",
"address": "IP adres"
},
@@ -1243,7 +1258,7 @@
"showLogsAction": "Toon logbestanden"
},
"configureIp": {
"title": "Configureer IP aanbieder",
"title": "Configureer IPv4 aanbieder",
"providerGenericDescription": "Het publieke IP adres van deze server wordt automatisch gedetecteerd."
},
"ipv4": {
@@ -1477,7 +1492,8 @@
"upload": {
"title": "Uploaden bestand naar {{ name }}"
},
"uploadToTmp": "Upload naar /tmp"
"uploadToTmp": "Upload naar /tmp",
"uploadTo": "Upload naar {{ path }}"
},
"filemanager": {
"title": "Bestandsbeheer",
@@ -1699,7 +1715,7 @@
"setupDnsInfo": "Gebruik deze optie om automatisch e-mail gerelateerde DNS records in te stellen. Het nu niet inschakelen kan handig zijn om eerst e-mail boxen aan te maken en <a href=\"{{ importEmailDocsLink }}\">e-mails te importeren</a> voor ingebruikname.",
"enableAction": "Inschakelen",
"description": "Hiermee wordt Cloudron zo geconfigureerd dat e-mails ontvangen worden voor <b>{{ domain }}</b>. In de documentatie staat beschreven welke <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">benodigde poorten</a> ingesteld dienen te worden voor Cloudron Email.",
"cloudflareInfo": "Het domein <code>{{ adminDomain }}</code> wordt beheerd door Cloudflare. Zorg ervoor dat Cloudflare proxying uitgeschakeld is voor <code>{{ mailFqdn }}</code> en ingesteld is op <code>DNS only</code>. Dit is noodzakelijk omdat Cloudflare geen e-mail-proxy kan uitvoeren."
"cloudflareInfo": "Het domein van de mailserver <code>{{ adminDomain }}</code> wordt beheerd door Cloudflare. Zorg ervoor dat Cloudflare proxying uitgeschakeld is voor <code>{{ mailFqdn }}</code> en ingesteld is op <code>DNS only</code>. Dit is noodzakelijk omdat Cloudflare geen e-mail-proxy kan uitvoeren."
},
"disableEmailDialog": {
"title": "E-mail Server voor {{ domain }} uitschakelen?",
@@ -1776,7 +1792,8 @@
"2faToken": "2FA Token (indien ingeschakeld)",
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
"errorIncorrect2FAToken": "2FA token is niet geldig",
"errorInternal": "Interne fout, probeer later opnieuw"
"errorInternal": "Interne fout, probeer later opnieuw",
"loginWith": "Login met Cloudron"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1854,7 +1871,8 @@
"es": "Spaans",
"ru": "Russisch",
"pt": "Portugees",
"da": "Deens"
"da": "Deens",
"id": "Indonesisch"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Wachtwoord herstellen",
-156
View File
@@ -1,156 +0,0 @@
{
"apps": {
"title": "As Minhas Aplicações",
"noApps": {
"description": "Que tal instalar algumas? Vê a <a href=\"{{ appStoreLink }}\">Loja de Aplicações</a>",
"title": "Sem aplicações instaladas!"
},
"groupsFilterHeader": "Selecionar Grupo",
"addApplinkAction": "Adicionar Applink",
"noAccess": {
"title": "Não tem acesso a nenhuma aplicação.",
"description": "Assim que tiver, elas vão aparecer aqui."
},
"configActionTooltip": "Configurar",
"logsActionTooltip": "Eventos",
"infoActionTooltip": "Informação",
"adminPageActionTooltip": "Página de Adminstração",
"searchPlaceholder": "Pesquisar Aplicações",
"stateFilterHeader": "Todos os Estados",
"tagsFilterHeader": "Etiquetas: {{ tags }}",
"tagsFilterHeaderAll": "Todas as Etiquetas",
"domainsFilterHeader": "Todos os Domínios",
"auth": {
"sso": "Entrar com as credenciais Cloudron",
"nosso": "Entrar com conta dedicada",
"email": "Entrar com endereço de email"
},
"addAppAction": "Adicionar Aplicação",
"addAppproxyAction": "Adicionar Appproxy",
"filter": {
"clearAll": "Limpar Tudo"
}
},
"main": {
"displayName": "Nome de Apresentação",
"rebootDialog": {
"warning": "Reiniciar o servidor irá causar que todas as aplicações instaladas neste Cloudron fiquem indisponíveis temporariamente!",
"description": "Utilize isto para aplicar atualizações de segurança ou se experienciar comportamento inesperado. Todas as aplicações e serviços em execução neste Cloudron vão iniciar automaticamente quando o reinício estiver completo.",
"title": "Realmente reiniciar o servidor?",
"rebootAction": "Reiniciar agora"
},
"offline": "O Cloudron está offline. A ligar novamente…",
"dialog": {
"cancel": "Cancelar",
"save": "Guardar",
"close": "Fechar",
"no": "Não",
"yes": "Sim"
},
"logout": "Terminar Sessão",
"username": "Nome de Utilizador",
"actions": "Ações",
"table": {
"date": "Data"
},
"pagination": {
"next": "seguinte",
"prev": "anterior",
"perPageSelector": "Mostrar {{ n }} por página"
},
"action": {
"reboot": "Reiniciar",
"logs": "Eventos"
},
"clipboard": {
"copied": "Copiado para a área de transferência",
"clickToCopy": "Clique para copiar",
"clickToCopyBackupId": "Clique para copiar o ID da cópia de segurança"
},
"searchPlaceholder": "Pesquisar",
"multiselect": {
"selected": "{{ n }} selecionados",
"select": "Selecionar",
"filterPlaceholder": "Escreva para filtrar opções"
},
"prettyDate": {
"justNow": "agora mesmo",
"yeserday": "Ontem",
"minutesAgo": "{{ m }} minutos atrás",
"hoursAgo": "{{ h }} horas atrás"
},
"navbar": {
"users": "Utilizadores"
},
"disableAction": "Desativar",
"enableAction": "Ativar",
"statusEnabled": "Ativado",
"statusDisabled": "Desativado"
},
"appstore": {
"category": {
"analytics": "Estatísticas",
"game": "Jogos",
"project": "Gestão de Projetos",
"all": "Tudo",
"popular": "Popular",
"newApps": "Novas Aplicações",
"chat": "Chat",
"blog": "Blog",
"document": "Documentos",
"crm": "CRM",
"forum": "Fórum",
"gallery": "Galeria",
"finance": "Finanças",
"git": "Alojamento de Código",
"email": "Email",
"hosting": "Alojamento Web",
"media": "Multimédia",
"learning": "Aprendizagem",
"notes": "Notas",
"sync": "Sincronização de Ficheiros",
"wiki": "Wiki",
"vpn": "VPN",
"federated": "Federados"
},
"installDialog": {
"lastUpdated": "Última atualização a {{ date }}",
"locationPlaceholder": "Deixe em branco para usar o domínio de raiz",
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores. Esta definição determina se a aplicação está ou não visível no painel do utilizador.",
"memoryRequirement": "Requere pelo menos {{ size }} de memória",
"location": "Localização",
"manualWarning": "Adicione um registo A manualmente para <b>{{ location }}</b> apontando para o endereço IP público deste Cloudron",
"userManagement": "Gestão de utilizadores",
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
"installAnywayAction": "Instalar na mesma",
"doInstallAction": "Instalar {{ dnsOverwrite ? 'e sobrescrever DNS' : '' }}",
"userManagementSelectUsers": "Apenas permitir os seguintes utilizadores e grupos",
"errorUserManagementSelectAtLeastOne": "Selecione pelo menos um utilizador ou grupo",
"users": "Utilizadores",
"groups": "Grupos",
"configuredForCloudronEmail": "Esta aplicação está pré-configurada para ser utilizada com o <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email do Cloudron</a>.",
"lowOnResources": "Este Cloudron está baixo em recursos.",
"pleaseUpgradeServer": "Por favor, atualize para um servidor com mais memória. Em alternativa, liberte recursos removendo aplicações que não utiliza.",
"subscriptionRequired": "Para instalar mais aplicação, uma subscrição paga é necessária.",
"setupSubscriptionAction": "Configurar Subscrição",
"installAction": "Instalar",
"cloudflarePortWarning": "O proxy do Cloudflare deve estar desativado para o domínio da aplicação para que possa aceder a esta porta",
"titleAndVersion": "Esta aplicação inclui {{ title }} {{ version }}"
},
"title": "Loja de Aplicações",
"searchPlaceholder": "Pesquise por alternativas como Github, Dropbox, Slack, Trello, …",
"noAppsFound": "Nenhuma aplicação encontrada.",
"appMissing": "Falta uma aplicação? Contacte-nos.",
"unstable": "Instável",
"appNotFoundDialog": {
"description": "Não existe nenhuma aplicação <b>{{ appId }}</b> com a versão <b>{{ version }}</b>.",
"title": "Aplicação não encontrada"
},
"accountDialog": {
"titleSignUp": "Registar com Cloudron.io",
"titleLogin": "Entrar com Cloudron.io"
}
}
}
+68 -26
View File
@@ -30,6 +30,9 @@
"addApplinkAction": "Добавить App Link",
"filter": {
"clearAll": "Очистить все"
},
"apps": {
"count": "Всего приложений: {{ count }}"
}
},
"main": {
@@ -49,7 +52,8 @@
"justNow": "только что",
"yeserday": "Вчера",
"minutesAgo": "{{ m }} минут назад",
"hoursAgo": "{{ h }} часов назад"
"hoursAgo": "{{ h }} часов назад",
"never": "Никогда"
},
"logout": "Выйти",
"dialog": {
@@ -162,7 +166,10 @@
"loginAction": "Логин",
"switchToSignUpAction": "Ещё нет учётной записи? Зарегистрироваться",
"createAccountAction": "Создать учётную запись",
"switchToLoginAction": "Уже есть учётная запись? Войти"
"switchToLoginAction": "Уже есть учётная запись? Войти",
"setupWithTokenAction": "Настройка",
"setupToken": "Настроить Токен",
"titleToken": "Войти с Настроенным Токеном"
},
"title": "Магазин приложений",
"noAppsFound": "Приложения не найдены.",
@@ -217,7 +224,7 @@
"require2FAWarning": "Сперва настройте 2FA, чтобы иметь доступ к аккаунту в будущем."
},
"externalLdap": {
"description": "Cloudron будет синхронизировать пользователей и группы с внешнего сервера LDAP или ActiveDirectory. Проверка пароля для аутентификации таких пользователей выполняется на внешнем сервере. Синхронизация не запускается автоматически, ее нужно активировать вручную.",
"description": "Эта настройка будет сихронизировать и идентифицировать пользователй и группы из внешнего сервера LDAP или AcriveDirectory. Синхронизация запускается с периодичностью, но также может быть запущена вручную.",
"bindPassword": "Привязать пароль (необязательно)",
"bindUsername": "Привязать Уникальное имя (DN)/Имя пользователя (необязательно)",
"title": "Подключиться к удалённому каталогу",
@@ -234,13 +241,14 @@
"groupFilter": "Фильтр группы",
"groupnameField": "Поле с именем группы",
"auth": "Авторизоваться",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа в Cloudron",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа",
"showLogsAction": "Показать логи",
"syncAction": "Синхронизировать",
"configureAction": "Настроить",
"errorSelfSignedCert": "Сервер использует недействительный или самоподписанный сертификат.",
"providerOther": "Другое",
"providerDisabled": "Отключить"
"providerDisabled": "Отключить",
"disableWarning": "Источник аутентификации будет сброшен до локальных паролей для всех активных пользователей."
},
"subscriptionDialog": {
"title": "Требуется подписка",
@@ -269,7 +277,9 @@
"errorDisplayNameRequired": "Требуется имя",
"activeCheckbox": "Пользователь активен",
"fallbackEmailPlaceholder": "Необязательно. Если не указано, будет использоваться основной почтовый ящик",
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации"
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации",
"external2FA": "Настройка 2FA осуществляется внешним ресурсом аутентификации",
"ldapGroups": "Группы LDAP"
},
"deleteUserDialog": {
"title": "Удалить пользователя {{ username }}",
@@ -356,7 +366,7 @@
"exposedLdap": {
"title": "Сервер LDAP",
"ipRestriction": {
"description": "Сервер каталогов может быть ограничен для определённого круга IP адресов.",
"description": "Ограничьте доступ к серверу каталогов только для определённого круга IP-адресов и диапазонов. Строки, начинающиеся с <code>#</code>, будут считаться комментарием.",
"placeholder": "IP-адреса или подсети, разделённые строками",
"label": "Ограничить доступ"
},
@@ -366,7 +376,8 @@
"label": "Привязать пароль",
"description": "Все запросы LDAP должны быть идентифицированы при помощи данного секрета и уникального имени пользователя (DN) <i>{{ userDN }}</i>",
"url": "URL сервера"
}
},
"cloudflarePortWarning": "Для доступа к LDAP серверу через домен панели управления проксирование Cloudflare должно быть выключено"
},
"userImportDialog": {
"title": "Импорт пользователей",
@@ -456,7 +467,10 @@
"changeEmail": {
"title": "Изменить главный адрес электронной почты",
"errorEmailInvalid": "Неверный адрес электронной почты",
"errorEmailRequired": "Требуется действительный адрес электронной почты"
"errorEmailRequired": "Требуется действительный адрес электронной почты",
"email": "Новый адрес электронной почты",
"password": "Пароль для подтверждения",
"errorWrongPassword": "Неверный пароль"
},
"changeFallbackEmail": {
"title": "Изменить пароль электронной почты восстановления",
@@ -525,7 +539,8 @@
"packageVersion": "Версия контейнера",
"lastUpdated": "Обновлен",
"checkForUpdatesAction": "Проверить обновления",
"repository": "Репозиторий"
"repository": "Репозиторий",
"installedAt": "Установлено"
},
"auto": {
"description": "Cloudron периодически проверяет Магазин приложений на наличие обновлений. Если Вы выключаете автоматические обновления, не забывайте применять их вручную.",
@@ -624,12 +639,12 @@
"title": "Лимит памяти",
"error": "Не получилось установить лимит памяти, попробуйте меньшее значение.",
"resizeAction": "Изменить",
"description": "Cloudron выделяет 50% этого значения из оперативной памяти и 50% из swap."
"description": "Максимальное количество ОЗУ, которое может использовать приложение."
},
"cpu": {
"setAction": "Установить",
"title": "Доля CPU",
"description": "Процент времени CPU, когда система находится под нагрузкой."
"setAction": "Масштабировать",
"title": "Лимит CPU",
"description": "Максимальный процент CPU, который может быть задействован в работе приложения"
}
},
"storage": {
@@ -843,6 +858,12 @@
"title": "Настроить Redis",
"enable": "Настроить использование Redis в приложении",
"disable": "Отключить Redis"
},
"infoTabTitle": "Информация",
"info": {
"notes": {
"title": "Заметки администратора"
}
}
},
"backups": {
@@ -961,7 +982,7 @@
},
"check": {
"noop": "Резервное копирование Cloudron выключено. Пожалуйста, убедитесь, что на сервере настроены альтернативные способы резервного копирования. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers .",
"sameDisk": "Cloudron сохраняет резервные копии на том же диске, где находится он сам. Это опасно, и может привести к потере данных в случае ошибки диска. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers для выбора облачного поставщика."
"sameDisk": "В настоящий момент резервные копии сохраняются на системный диск с установленным Cloudron. Обратите внимание, что при полном заполнении диска бэкапами, Cloudron прекратит свою работу. Также, в случае поломки диска, вы можете полностью потерять доступ к вашим данным и бэкапам. Советуем ознакомиться с документацией https://docs.cloudron.io/backups/#storage-providers для выбора внешнего хранилища бэкапов."
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -985,7 +1006,9 @@
},
"changeLogo": {
"title": "Выбрать изображение Cloudron"
}
},
"backgroundImage": "Фоновое изображение экрана входа",
"clearBackgroundImage": "Очистить"
},
"emails": {
"title": "Электронная почта",
@@ -1112,8 +1135,8 @@
},
"network": {
"ip": {
"title": "IP Адрес",
"description": "Cloudron будет использовать данный IP адрес для настройки записей DNS.",
"title": "IPv4",
"description": "Cloudron будет использовать данный IPv4 адрес для настройки A записей DNS.",
"provider": "Источник",
"interface": "Имя сетевого интерфейса",
"configure": "Настроить",
@@ -1138,7 +1161,7 @@
"showLogsAction": "Показать логи"
},
"configureIp": {
"title": "Настроить источник IP",
"title": "Настроить поставщика IPv4",
"providerGenericDescription": "Публичный IP адрес сервера будет обнаружен автоматически."
},
"ipv4": {
@@ -1190,7 +1213,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Отменена и завершена",
"subscriptionSetupAction": "Обновить до Premium",
"subscriptionChangeAction": "Изменить подписку",
"subscriptionChangeAction": "Управление подпиской",
"subscriptionReactivateAction": "Реактивировать подписку",
"emailNotVerified": "Электронная почта не подтверждена"
},
@@ -1283,6 +1306,10 @@
"enableAction": "Включить SSH доступ",
"title": "Удалённая поддержка",
"description": "Выберите эту опцию, чтобы позволить сотрудникам поддержки подключиться к Вашему серверу через SSH."
},
"help": {
"title": "Помощь",
"description": "Для поддержки и помощи, пожалуйста, воспользуйтесь следующими ресурсами:\n* [Форум Cloudron]({{ forumLink }}) - пожалуйста, задавайте вопросы в соответствующих темах Поддержки или конкретных приложений.\n* [Документация Cloudron & База знаний]({{ docsLink }})\n* [Создание сторонних приложений и API]({{ packagingLink }})\n"
}
},
"system": {
@@ -1307,7 +1334,19 @@
"graphTitle": "Процент",
"graphSubtext": "Отображаются приложения, использующие более {{ threshold }} CPU"
},
"selectPeriodLabel": "Выберите период"
"selectPeriodLabel": "Выберите период",
"info": {
"platformVersion": "Версия Платформы",
"product": "Продукт",
"vendor": "Поставщик",
"memory": "Память",
"uptime": "Аптайм",
"activationTime": "Время создания Cloudron",
"title": "Информация"
},
"graphs": {
"title": "Графики"
}
},
"eventlog": {
"title": "Журнал",
@@ -1393,7 +1432,8 @@
"ovhEndpoint": "Конечная точка",
"ovhConsumerKey": "Ключ пользователя",
"ovhAppKey": "Ключ приложения",
"ovhAppSecret": "Секрет приложения"
"ovhAppSecret": "Секрет приложения",
"deSecToken": "deSEC Токен"
},
"addDomain": "Добавить домен",
"removeDialog": {
@@ -1467,7 +1507,8 @@
"renameDialog": {
"newName": "Новое имя",
"rename": "Переименовать",
"title": "Переименовать {{ fileName }}"
"title": "Переименовать {{ fileName }}",
"reallyOverwrite": "Файл с таким именем уже существует. Хотите перезаписать его?"
},
"chownDialog": {
"newOwner": "Новый владелец",
@@ -1590,8 +1631,8 @@
"title": "Включить электронную почту для {{ domain }}?",
"setupDnsCheckbox": "Установить почтовые DNS записи",
"enableAction": "Включить",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Прости ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен только режим <code>DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Рекомендуем ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Почтовый домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен режим <code>только DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"setupDnsInfo": "Используйте данную опцию, чтобы автоматически настроить относящиеся к электронной почте записи DNS. Вы можете не отмечать её сразу, чтобы предварительно создать почтовые ящики и <a href=\"{{ importEmailDocsLink }}\">импортировать письма</a>."
},
"backAction": "Вернуться к электронной почте",
@@ -1750,7 +1791,8 @@
"2faToken": "2FA Токен (если включен)",
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже"
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginWith": "Войти с Cloudron"
},
"passwordReset": {
"title": "Сброс пароля",
+181 -114
View File
@@ -34,6 +34,12 @@
</h5>
</div>
<div class="modal-body">
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
</div>
</div>
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
@@ -57,7 +63,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#ftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#sftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
</div>
<div class="modal-body">
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
@@ -329,32 +335,45 @@
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.mountPoint }" ng-show="importBackup.provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
<label class="control-label" for="inputImportMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputImportMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
</div>
<!-- S3/Minio/SOS/GCS/SSHFS/CIFS/NFS/B2/Mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.prefix }" ng-show="importBackup.provider !== 'filesystem'">
<label class="control-label" for="inputImportBackupPrefix">{{ 'backups.configureBackupStorage.prefix' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.prefix" id="inputImportBackupPrefix" name="prefix" ng-disabled="importBackup.busy" placeholder="Prefix for backup file names">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="configureBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="importBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="configureBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="importBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="importBackup.provider === 'cifs'">
<label>
<input type="checkbox" ng-model="importBackup.mountOptions.seal">{{ 'backups.configureBackupStorage.cifsSealSupport' | tr }}</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="importBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-show="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'" ng-class="{ 'has-error': importBackup.error.diskPath }">
<label class="control-label" for="configureBackupDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.diskPath" id="configureBackupDiskPath" name="diskPath" ng-disabled="importBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'">
<label class="control-label" for="importBackupDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.diskPath" id="importBackupDiskPath" name="diskPath" ng-disabled="importBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'">
</div>
<!-- remotePath contains the prefix as well -->
@@ -365,26 +384,26 @@
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="configureBackupUsername" name="username" ng-disabled="importBackup.busy">
<label class="control-label" for="importBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="importBackupUsername" name="username" ng-disabled="importBackup.busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="configureBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
<label class="control-label" for="importBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="importBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="configureBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="importBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
<label class="control-label" for="importBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="importBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': importBackup.error.region }" ng-show="importBackup.provider === 's3'">
@@ -607,17 +626,17 @@
<p class="text-small text-warning" ng-show="clone.domain.provider === 'noop' || clone.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((clone.subdomain ? clone.subdomain + '.' : '') + clone.domain.domain) }"></p>
<div class="has-error text-center" ng-show="clone.error.port">{{ clone.error.port }}</div>
<div ng-repeat="(env, info) in clone.portBindingsInfo">
<div ng-repeat="(env, info) in clone.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!clone.itemName{{$index}}.$dirty && clone.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portBindingsEnabled[env]">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
<input type="number" class="form-control" ng-model="clone.portBindings[env]" ng-disabled="!clone.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="clone.ports[env]" ng-disabled="!clone.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="clone.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -701,6 +720,7 @@
<div class="row app-configure-links-container" ng-show="view">
<div class="col-sm-2">
<div class="app-configure-links">
<div ng-click="setView('info')" ng-class="{ 'active': view === 'info' }">{{ 'app.infoTabTitle' | tr }}</div>
<div ng-click="setView('display')" ng-class="{ 'active': view === 'display' }">{{ 'app.displayTabTitle' | tr }}</div>
<div ng-click="setView('location')" ng-class="{ 'active': view === 'location' }" ng-show="app.accessLevel === 'admin'">{{ 'app.locationTabTitle' | tr }}</div>
<div ng-click="setView('proxy')" ng-class="{ 'active': view === 'proxy' }" ng-show="app.type === APP_TYPES.PROXIED">Proxy</div>
@@ -719,7 +739,101 @@
<div ng-click="setView('uninstall')" ng-class="{ 'active': view === 'uninstall' }" ng-show="app.accessLevel === 'admin'">{{ 'app.uninstallTabTitle' | tr }}</div>
</div>
</div>
<div class="col-sm-8 card-container">
<div class="card" ng-show="view === 'info'">
<p>
<label class="control-label">{{ 'app.updates.info.title' | tr }}</label>
<a href="" class="pull-right" ng-click="info.showDoneChecklist = true" ng-show="info.hasOldChecklist && !info.showDoneChecklist">Show Checklist</a>
<a href="" class="pull-right" ng-click="info.showDoneChecklist = false" ng-show="info.showDoneChecklist">Hide Checklist</a>
</p>
<div ng-repeat="(key, item) in app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
<button class="btn btn-xs btn-default" style="margin-left: 10px;" ng-click="info.checklistAck(item, key)">Done</button>
</div>
</div>
<div ng-repeat="(key, item) in app.checklist" ng-show="info.showDoneChecklist">
<div class="checklist-item checklist-item-acknowledged" ng-show="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
<span class="text-muted text-small">{{ item.changedBy }} {{ item.changedAt | prettyDate }}</span>
</div>
</div>
<div style="margin-top: 10px"></div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.creationTime | prettyDate }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
</div>
</div>
<br/>
<p><label class="control-label">{{ 'app.info.notes.title' | tr }}</label><i ng-show="!info.notes.editing" class="info-edit-indicator fa fa-pencil-alt" ng-click="info.notes.edit()"></i></p>
<div class="row">
<div class="col-md-12" ng-show="!info.notes.busy">
<div ng-show="!info.notes.editing">
<div ng-show="info.notes.content" ng-bind-html="info.notes.content | markdown2html"></div>
<div ng-show="!info.notes.content" class="text-muted hand" ng-click="info.notes.edit()">{{ info.notes.placeholder }}</div>
</div>
<div ng-show="info.notes.editing" class="text-right">
<textarea id="adminNotesTextarea" ng-trim="false" style="white-space: pre-wrap; margin-bottom: 5px" ng-model="info.notes.content" class="form-control" rows="10"></textarea>
<button class="btn btn-default" ng-click="info.notes.dismiss()" ng-disabled="info.notes.busySave">{{ 'main.dialog.cancel' | tr }}</button>
<button class="btn btn-success" ng-click="info.notes.submit()" ng-disabled="info.notes.busySave"><i class="fa fa-circle-notch fa-spin" ng-show="info.notes.busySave"></i> {{ 'app.display.saveAction' | tr }}</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
</div>
</div>
</div>
<div class="card" ng-show="view === 'display'">
<div class="row">
<div class="col-md-12">
@@ -740,7 +854,7 @@
</div>
<div id="previewIcon" class="app-custom-icon" ng-click="display.showCustomIconSelector()">
<img ng-src="{{ display.iconUrl() || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)"/>
<div class="overlay"></div>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" style="font-weight: normal;" ng-click="display.resetCustomIcon()">{{ 'app.display.iconResetAction' | tr }}</a>
<input type="file" id="iconFileInput" style="display: none" accept="image/png"/>
@@ -753,8 +867,7 @@
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="display.submit()" ng-disabled="(!display.icon.data && !displayForm.$dirty) || display.$invalid || display.busy"><i class="fa fa-circle-notch fa-spin" ng-show="display.busy"></i> {{ 'app.display.saveAction' | tr }}</button>
</div>
<button class="btn btn-outline btn-primary pull-right" ng-click="display.submit()" ng-disabled="(!display.icon.data && !displayForm.$dirty) || display.$invalid || display.busy"><i class="fa fa-circle-notch fa-spin" ng-show="display.busy"></i> {{ 'app.display.saveAction' | tr }}</button> </div>
</div>
</div>
@@ -819,18 +932,18 @@
</div>
<div class="has-error text-center" ng-show="location.error.port">{{ location.error.port }}</div>
<div ng-repeat="(env, info) in location.portBindingsInfo">
<div ng-repeat="(env, info) in location.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!portInfo_form.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
<span ng-show="info.portCount" style="display: block; float: right">({{ info.portCount }} ports) {{ location.portBindings[env] }} to {{ location.portBindings[env] + info.portCount - 1 }}</span>
<span ng-show="info.portCount" style="display: block; float: right">{{ location.ports[env] }} to {{ location.ports[env] + info.portCount - 1 }} ({{ info.portCount }} ports)</span>
</label>
<input type="number" class="form-control" ng-model="location.portBindings[env]" ng-disabled="!location.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<input type="number" class="form-control" ng-model="location.ports[env]" ng-disabled="!location.portsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -995,12 +1108,12 @@
<form role="form" name="resourcesForm" ng-submit="resources.submitMemoryLimit()" autocomplete="off">
<div class="form-group">
<label class="control-label" for="memoryLimit">{{ 'app.resources.memory.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ resources.memoryLimit | prettyBinarySize:'Default (256 MiB)' }}</b></label>
<div style="padding: 0 10px;">
<slider id="memoryLimit" ng-model="resources.memoryLimit" step="134217728" tooltip="hide" ticks="resources.memoryTicks" ticks-snap-bounds="67108864"></slider>
</div>
<p>{{ 'app.resources.memory.description' | tr }}</p>
<input type="range" id="memoryLimit" ng-model="resources.memoryLimit" step="134217728" min="{{ resources.memoryTicks[0] }}" max="{{ resources.memoryTicks[resources.memoryTicks.length-1] }}" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option ng-repeat="limit in resources.memoryTicks" value="{{ limit }}"></option>
</datalist>
</div>
<input class="ng-hide" type="submit" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy"/>
</form>
</div>
</div>
@@ -1009,34 +1122,31 @@
<span ng-show="resources.error.memoryLimit" class="text-danger">{{ 'app.resources.memory.error' | tr }}</span>
</div>
<div class="col-md-4 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitMemoryLimit()" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="resources.busy"></i> {{ 'app.resources.memory.resizeAction' | tr }}
</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitMemoryLimit()" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">{{ 'app.resources.memory.resizeAction' | tr }}</button>
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-12">
<form role="form" name="resourcesForm" ng-submit="resources.submitCpuShares()" autocomplete="off">
<form role="form" name="resourcesForm" ng-submit="resources.submitCpuQuota()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label" for="cpuShares">{{ 'app.resources.cpu.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cpu-shares" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ (resources.cpuShares * 100 / 1024 | number:0) + ' %' }}</b></label>
<label class="control-label" for="cpuQuota">{{ 'app.resources.cpu.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cpu-quota" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ resources.cpuQuota + ' %' }}</b></label>
<p>{{ 'app.resources.cpu.description' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="cpuShares" ng-model="resources.cpuShares" ticks="[32, 256, 512, 768, 1024]" step="32" ticks-snap-bounds="32" min="32" max="1024" tooltip="hide"></slider>
</div>
<input type="range" id="cpuQuota" ng-model="resources.cpuQuota" step="1" min="1" max="100"/>
<datalist id="cpuQuotaTicks">
<option value="25"></option>
<option value="50"></option>
<option value="75"></option>
</datalist>
</div>
<input class="ng-hide" type="submit" ng-disabled="resources.cpuShares === resources.currentCpuShares || resourcesForm.$invalid || resources.busyCpuShares"/>
</fieldset>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitCpuShares()" ng-disabled="resources.cpuShares === resources.currentCpuShares || resourcesForm.$invalid || resources.busyCpuShares || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="resources.busyCpuShares"></i> {{ 'app.resources.cpu.setAction' | tr }}
</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitCpuQuota()" ng-disabled="resources.cpuQuota === resources.currentCpuQuota || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">{{ 'app.resources.cpu.setAction' | tr }}</button>
</div>
</div>
</div>
@@ -1356,7 +1466,8 @@
<thead>
<tr>
<th class="col-md-2">{{ 'eventlog.time' | tr }}</th> <!-- "minutes ago" takes space -->
<th class="col-md-8">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-6">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2" style="text-align: right;">
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showPrevPage()" ng-disabled="eventlog.busy || eventlog.currentPage <= 1"><i class="fa fa-angle-double-left"></i></button>
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showNextPage()" ng-disabled="eventlog.busy || eventlog.perPage > eventlog.eventLogs.length"><i class="fa fa-angle-double-right"></i></button>
@@ -1366,10 +1477,14 @@
<tbody ng-repeat="eventLog in eventlog.eventLogs">
<tr ng-click="eventlog.showDetails(eventLog)" class="hand">
<td><span uib-tooltip="{{ eventLog.raw.creationTime | prettyLongDate }}" class="arrow">{{ eventLog.raw.creationTime | prettyDate }}</span></td>
<td>{{ eventLog.source }}</td>
<td style="word-wrap: anywhere;" colspan="2" ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="eventlog.activeEventLog === eventLog">
<td colspan="3"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
<td colspan="4">
<p ng-show="eventLog.raw.source.ip">Source IP: <code>{{ eventLog.raw.source.ip }}</code></p>
<pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre>
</td>
</tr>
</tbody>
</table>
@@ -1440,76 +1555,28 @@
</div>
<div class="card" ng-show="view === 'updates'">
<p><label class="control-label">{{ 'app.updates.info.title' | tr }}</label></p>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<p><label class="control-label">{{ 'app.updatesTabTitle' | tr }}</label></p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
<div class="col-md-12">
<p>
<span ng-bind-html="'app.updates.auto.description' | tr:{ appStoreLink: 'https://www.cloudron.io/store/index.html' }"></span>
<span ng-show="app.appStoreId && updates.enableAutomaticUpdate" class="text-success">{{ 'app.updates.auto.enabled' | tr }}</span>
<span ng-show="app.appStoreId && !updates.enableAutomaticUpdate" class="text-danger">{{ 'app.updates.auto.disabled' | tr }}</span>
<span ng-show="!app.appStoreId" class="text-danger">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</p>
</div>
</div>
<br/>
<div class="row" ng-show="app.appStoreId">
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="!updates.busyCheck && !(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version)">{{ 'app.updates.noUpdates' | tr }}</span>
</div>
<div class="col-md-6 text-right">
<button type="button" class="btn" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" ng-hide="!(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update') || app.taskId" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="updates.check()" ng-disabled="updates.busyCheck"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
</div>
</div>
<hr/>
<div class="row" ng-show="!app.appStoreId">
<div class="row">
<div class="col-md-12">
<span class="text-danger pull-right">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</div>
</div>
<div ng-show="app.appStoreId" class="row">
<div class="col-md-12">
<label class="control-label">{{ 'app.updates.auto.title' | tr }}</label>
<p>{{ 'app.updates.auto.description' | tr }}</p>
</div>
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="app.enableAutomaticUpdate">{{ 'app.updates.auto.enabled' | tr }}</span>
<span class="text-danger" ng-hide="app.enableAutomaticUpdate">{{ 'app.updates.auto.disabled' | tr }}</span>
</div>
<div class="col-md-6">
<button class="btn btn-primary pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="{ 'btn-danger': app.enableAutomaticUpdate }" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ app.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<button class="btn pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="updates.enableAutomaticUpdate ? 'btn-danger' : 'btn-success'" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ updates.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<!-- check for updates button is always visible -->
<button class="btn btn-default btn-outline pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-click="updates.check()" ng-disabled="updates.busyCheck || !app.appStoreId"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
<!-- show update button only if update available -->
<button class="btn pull-right" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update' && !app.taskId" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
@@ -1536,7 +1603,7 @@
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<!-- <td><div class="hand clipboard" data-clipboard-text="{{ backup.id }}" uib-tooltip="{{ copyBackupIdDone ? ('main.clipboard.copied' | tr) : ('main.clipboard.clickToCopyBackupId' | tr) }}" tooltip-placement="right"><i class="fa fa-copy"></i></div></td> -->
<td ng-click="backupDetails.show(backup)" class="hand"><div>v{{ backup.packageVersion }}</div></td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td class="text-center" style="vertical-align: bottom">
<div class="dropdown">
<button class="btn btn-xs btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+159 -71
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular */
/* global angular, localStorage, document, FileReader */
/* global $ */
/* global async */
/* global RSTATES */
@@ -136,6 +136,73 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
$scope.info = {
showDoneChecklist: false,
hasOldChecklist: false,
notes: {
busy: true,
busySave: false,
editing: false,
content: '',
placeholder: 'Add admin notes here...',
edit: function () {
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = true;
setTimeout(function () { document.getElementById('adminNotesTextarea').focus(); }, 1);
},
dismiss: function () {
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
},
submit: function () {
$scope.info.notes.busySave = true;
// skip saving if unchanged from postInstall
if ($scope.info.notes.content === $scope.app.manifest.postInstallMessage) {
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
return;
}
Client.configureApp($scope.app.id, 'notes', { notes: $scope.info.notes.content }, function (error) {
if (error) return console.error('Failed to save notes.', error);
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
});
});
}
},
show: function () {
$scope.info.hasOldChecklist = !!Object.keys($scope.app.checklist).find((k) => { return $scope.app.checklist[k].acknowledged; });
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
$scope.info.notes.busy = false;
},
checklistAck(item, key) {
item.acknowledged = true;
// item.acknowledged = !item.acknowledged;
Client.ackAppChecklistItem($scope.app.id, key, item.acknowledged, function (error) {
if (error) return console.error('Failed to ack checklist item.', error);
$scope.info.hasOldChecklist = true;
refreshApp($scope.app.id);
});
}
};
$scope.display = {
busy: false,
error: {},
@@ -238,9 +305,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
redirectDomains: [],
aliasDomains: [],
portBindings: {},
portBindingsEnabled: {},
portBindingsInfo: {},
ports: {},
portsEnabled: {},
portInfo: {},
addRedirectDomain: function (event) {
event.preventDefault();
@@ -302,18 +369,18 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
});
$scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.portInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
$scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
for (var env in $scope.location.portBindingsInfo) {
for (var env in $scope.location.portInfo) {
if (app.portBindings && app.portBindings[env]) {
$scope.location.portBindings[env] = app.portBindings[env];
$scope.location.portBindingsEnabled[env] = true;
$scope.location.ports[env] = app.portBindings[env].hostPort;
$scope.location.portsEnabled[env] = true;
} else {
$scope.location.portBindings[env] = $scope.location.portBindingsInfo[env].defaultValue || 0;
$scope.location.portBindingsEnabled[env] = false;
$scope.location.ports[env] = $scope.location.portInfo[env].defaultValue || 0;
$scope.location.portsEnabled[env] = false;
}
}
},
@@ -333,11 +400,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports from portBindings
var portBindings = {};
for (var env in $scope.location.portBindings) {
if ($scope.location.portBindingsEnabled[env]) {
portBindings[env] = $scope.location.portBindings[env];
// only use enabled ports
var ports = {};
for (var env in $scope.location.ports) {
if ($scope.location.portsEnabled[env]) {
ports[env] = $scope.location.ports[env];
}
}
@@ -345,7 +412,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
overwriteDns: !!overwriteDns,
subdomain: $scope.location.subdomain,
domain: $scope.location.domain.domain,
portBindings: portBindings,
ports: ports,
secondaryDomains: secondaryDomains,
redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
aliasDomains: $scope.location.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };})
@@ -540,43 +607,49 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busy: false,
currentMemoryLimit: 0,
memoryLimit: 0,
memoryLimit: 0, // RAM
memoryTicks: [],
busyCpuShares: false,
currentCpuShares: 0,
cpuShares: 0,
currentCpuQuota: 0,
cpuQuota: 0,
show: function () {
var app = $scope.app;
$scope.resources.busy = true;
$scope.resources.error = {};
$scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024);
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.currentCpuShares = $scope.resources.cpuShares = app.cpuShares;
Client.getAppLimits(app.id, function (error, limits) {
if (error) return console.error(error);
Client.memory(function (error, result) {
if (error) console.error(error);
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
// create ticks starting from manifest memory limit. the memory limit here is just RAM
$scope.resources.memoryTicks = [];
var npow2 = Math.pow(2, Math.ceil(Math.log(limits.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
if (i >= (app.manifest.memoryLimit/1024/1024 || 0)) $scope.resources.memoryTicks.push(i * 1024 * 1024);
}
if (app.manifest.memoryLimit && $scope.resources.memoryTicks[0] !== app.manifest.memoryLimit) {
$scope.resources.memoryTicks.unshift(app.manifest.memoryLimit);
// we max system memory and current app memory for the case where the user configured the app on another server with more resources
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
for (var i = startTick; i <= nearest256m; i *= 2) {
$scope.resources.memoryTicks.push(i);
}
});
// for firefox widget update
$timeout(function() {
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota = app.cpuQuota;
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.busy = false;
}, 500);
},
submitMemoryLimit: function () {
$scope.resources.busy = true;
$scope.resources.error = {};
var memoryLimit = $scope.resources.memoryLimit === $scope.resources.memoryTicks[0] ? 0 : $scope.resources.memoryLimit;
Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit: memoryLimit }, function (error) {
const tmp = parseInt($scope.resources.memoryLimit);
const memoryLimit = tmp === $scope.resources.memoryTicks[0] ? 0 : tmp;
Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit }, function (error) {
if (error && error.statusCode === 400) {
$scope.resources.busy = false;
$scope.resources.error.memoryLimit = true;
@@ -594,19 +667,19 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
});
},
submitCpuShares: function () {
$scope.resources.busyCpuShares = true;
submitCpuQuota: function () {
$scope.resources.busy = true;
$scope.resources.error = {};
Client.configureApp($scope.app.id, 'cpu_shares', { cpuShares: $scope.resources.cpuShares }, function (error) {
Client.configureApp($scope.app.id, 'cpu_quota', { cpuQuota: parseInt($scope.resources.cpuQuota) }, function (error) {
if (error) return Client.error(error);
$scope.resources.currentCpuShares = $scope.resources.cpuShares;
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota;
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$timeout(function () { $scope.resources.busyCpuShares = false; }, 1000);
$timeout(function () { $scope.resources.busy = false; }, 1000);
});
});
},
@@ -714,8 +787,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.storage.error.storageVolumePrefix = error.message;
$scope.storage.busyDataDir = false;
return;
} else if (error) {
Client.error(error);
$scope.storage.busyDataDir = false;
return;
}
if (error) return Client.error(error);
$scope.storageDataDirForm.$setPristine();
@@ -1211,21 +1287,27 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busyUpdate: false,
busyAutomaticUpdates: false,
skipBackup: false,
enableAutomaticUpdate: true,
show: function () {
$scope.updates.skipBackup = false;
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
},
toggleAutomaticUpdates: function () {
$scope.updates.busyAutomaticUpdates = true;
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.app.enableAutomaticUpdate }, function (error) {
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) {
if (error) return Client.error(error);
refreshApp($scope.app.id, function (error) {
if (error) console.error(error);
$scope.updates.busyAutomaticUpdates = false;
$timeout(function () {
console.log($scope.updates.enableAutomaticUpdate, $scope.app.enableAutomaticUpdate);
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
$scope.updates.busyAutomaticUpdates = false;
}, 2000);
});
});
},
@@ -1359,7 +1441,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
provider: '',
bucket: '',
prefix: '',
mountPoint: '',
mountPoint: '', // for mountpoint
accessKeyId: '',
secretAccessKey: '',
gcsKey: { keyFileName: '', content: '' },
@@ -1370,7 +1452,17 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
remotePath: '',
password: '',
encryptedFilenames: true,
mountOptions: {}, // host, port, username, password, remoteDir, diskPath, user, privateKey
mountOptions: {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
user: '',
seal: true,
port: 22,
privateKey: ''
},
encrypted: false, // helps with ng-required when backupConfig is read from file
clearForm: function () {
@@ -1388,7 +1480,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.importBackup.password = '';
$scope.importBackup.encryptedFilenames = true;
$scope.importBackup.remotePath = '';
$scope.importBackup.mountOptions = {};
$scope.importBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
},
submit: function () {
@@ -1408,7 +1500,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
backupConfig.bucket = $scope.importBackup.bucket;
backupConfig.prefix = '';
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.accessKeyId = $scope.importBackup.accessKeyId;
backupConfig.secretAccessKey = $scope.importBackup.secretAccessKey;
@@ -1455,7 +1547,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.importBackup.bucket;
backupConfig.prefix = '';
backupConfig.prefix = $scope.importBackup.prefix;
try {
var serviceAccountKey = JSON.parse($scope.importBackup.gcsKey.content);
backupConfig.projectId = serviceAccountKey.project_id;
@@ -1475,10 +1567,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
} else if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
backupConfig.mountOptions = $scope.importBackup.mountOptions;
backupConfig.prefix = '';
backupConfig.prefix = $scope.importBackup.prefix;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountOptions = {};
backupConfig.mountPoint = $scope.mountPoint;
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.mountPoint = $scope.importBackup.mountPoint;
} else if (backupConfig.provider === 'filesystem') {
var parts = remotePath.split('/');
remotePath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
@@ -1702,9 +1794,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
needsOverwrite: false,
overwriteDns: false,
portBindings: {},
portBindingsInfo: {},
portBindingsEnabled: {},
ports: {},
portsEnabled: {},
portInfo: {},
show: function (backup) {
var app = $scope.app;
@@ -1726,11 +1818,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
$scope.clone.portBindingsInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
$scope.clone.portInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
// set default ports
for (var env in $scope.clone.portBindingsInfo) {
$scope.clone.portBindings[env] = $scope.clone.portBindingsInfo[env].defaultValue || 0;
$scope.clone.portBindingsEnabled[env] = true;
for (var env in $scope.clone.portInfo) {
$scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0;
$scope.clone.portsEnabled[env] = true;
}
$('#appCloneModal').modal('show');
@@ -1747,11 +1839,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.clone.portBindings) {
if ($scope.clone.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.clone.portBindings[env];
// only use enabled ports
var finalPorts = {};
for (var env in $scope.clone.ports) {
if ($scope.clone.portsEnabled[env]) {
finalPorts[env] = $scope.clone.ports[env];
}
}
@@ -1759,7 +1851,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
subdomain: $scope.clone.subdomain,
domain: $scope.clone.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
ports: finalPorts,
backupId: $scope.clone.backup.id,
overwriteDns: $scope.clone.overwriteDns
};
@@ -2156,16 +2248,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var backupConfig;
try {
backupConfig = JSON.parse(result.target.result);
let prefix = backupConfig.prefix;
backupConfig.prefix = ''; // so it can clear the form as well when we apply keys below
if (backupConfig.provider === 'filesystem') { // patch the remotePath to have the full path
backupConfig.remotePath = (prefix ? prefix + '/' : '') + backupConfig.backupFolder + '/' + backupConfig.remotePath;
if (backupConfig.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
backupConfig.remotePath = backupConfig.backupFolder + '/' + backupConfig.remotePath;
delete backupConfig.backupFolder;
} else {
backupConfig.remotePath = (prefix ? prefix + '/' : '') + backupConfig.remotePath;
}
} catch (e) {
console.error('Unable to parse backup config');
console.error('Unable to parse backup config', e);
return;
}
@@ -2191,7 +2279,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
if ($routeParams.view) { // explicit route in url bar
$scope.setView($routeParams.view, true /* skipViewShow */);
} else { // default
$scope.setView($scope.app.error ? 'repair' : 'display', true /* skipViewShow */);
$scope.setView($scope.app.error ? 'repair' : 'info', true /* skipViewShow */);
}
function done() {
+106 -34
View File
@@ -4,28 +4,30 @@
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appPostInstallConfirm.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
<h5 class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}
<span class="app-info-meta text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<div class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}<br/>
<span class="text-muted text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<br/>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl" class="text-small"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<br/>
</h5>
</div>
</div>
<div class="modal-body">
<!--
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
<div ng-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | markdown2html"></div>
-->
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
</div>
</div>
</div>
<div class="modal-footer">
<div class="form-group pull-left">
<input type="checkbox" id="appsPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appsPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
<a class="btn btn-success" ng-href="{{ 'https://' + appPostInstallConfirm.app.fqdn }}" target="_blank" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
</div>
</div>
</div>
@@ -57,7 +59,7 @@
</div>
<div id="previewIcon" class="app-custom-icon" ng-click="applinksEdit.showCustomIconSelector()">
<img ng-src="{{ applinksEdit.iconUrl() || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)"/>
<div class="overlay"></div>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" style="font-weight: normal;" ng-click="applinksEdit.resetCustomIcon()">{{ 'app.applinks.clearIconAction' | tr }}</a> - <span class="text-small">{{ 'app.applinks.clearIconDescription' | tr }}</span>
<input type="file" id="applinksEditIconFileInput" style="display: none" accept="image/png"/>
@@ -130,34 +132,36 @@
</div>
</div>
<h1 class="view-header" ng-show="installedApps.length > 0">
<h1 class="view-header" ng-show="installedApps.length > 0" style="padding-right: 0;">
{{ 'apps.title' | tr }}
<div class="view-header-search-bar">
<form class="form-inline">
<div class="input-group">
<input type="text" class="form-control" style="width: 300px" placeholder="{{ 'apps.searchPlaceholder' | tr }}" id="appSearch" ng-model="appSearch"/>
<input type="text" class="form-control" style="width: 300px" placeholder="{{ 'apps.searchPlaceholder' | tr }} ( / )" id="appSearch" ng-model="appSearch"/>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-class="{ 'active': showFilter, 'btn-warning': showFilter || selectedTags.length || selectedState.state || !selectedGroup._unset || !selectedDomain._alldomains }" ng-click="showFilter = !showFilter"><i class="fas fa-filter"></i></button>
<button class="btn btn-default" type="button" ng-class="{ 'active': showFilter }" ng-click="toggleFilter()"><i class="fas fa-filter"></i></button>
</span>
</div>
</form>
</div>
<div ng-show="showFilter" class="view-header-filter-bar">
<form class="form-inline">
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && groups.length > 1" ms-header="{{ selectedGroup.name }}" options="group.name for group in groups" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedState" ng-show="user.isAtLeastAdmin" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ selectedState }}" options="state.label for state in states" data-multiple="false"></multiselect>
<multiselect ng-model="selectedTags" ng-show="user.isAtLeastAdmin && tags.length > 0" ms-header="{{ 'apps.tagsFilterHeaderAll' | tr }}" ms-selected="{{ 'apps.tagsFilterHeader' | tr:{ tags: selectedTags.join(', ') } }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedDomain" data-compare-by="domain" ms-selected="{{ selectedDomain.domain }}" options="domain.domain for domain in filterDomains" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<button class="btn btn-warning" ng-disabled="!selectedTags.length && !selectedState.state && selectedGroup._unset && selectedDomain._alldomains" ng-click="clearAllFilter()">{{ 'apps.filter.clearAll' | tr }}</button>
<button class="btn btn-default" type="button" ng-click="toggleView()"><i class="fas" ng-class="{ 'fa-list': view === VIEWS.GRID, 'fa-grip': view === VIEWS.LIST }"></i></button>
</form>
</div>
</h1>
<div ng-show="showFilter" class="view-header-filter-bar">
<form class="form-inline">
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && groups.length > 1" ms-header="{{ selectedGroup.name }}" options="group.name for group in groups" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedState" ng-show="user.isAtLeastAdmin" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ selectedState }}" options="state.label for state in states" data-multiple="false"></multiselect>
<multiselect ng-model="selectedTags" ng-show="user.isAtLeastAdmin && tags.length > 0" ms-header="{{ 'apps.tagsFilterHeaderAll' | tr }}" ms-selected="{{ 'apps.tagsFilterHeader' | tr:{ tags: selectedTags.join(', ') } }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedDomain" data-compare-by="domain" ms-selected="{{ selectedDomain.domain }}" options="domain.domain for domain in filterDomains" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<!-- <button class="btn btn-primary" ng-disabled="!selectedTags.length && !selectedState.state && selectedGroup._unset && selectedDomain._alldomains" ng-click="clearAllFilter()">{{ 'apps.filter.clearAll' | tr }}</button> -->
</form>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-grid">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:labelOrFQDN">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/display" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div class="app-grid" ng-show="view === VIEWS.GRID">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}" tooltip-append-to-body="true">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/info" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div ng-show="app.type === APP_TYPES.LINK && isOperator(app)" ng-click="applinksEdit.show(app)" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></div>
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank">
<div class="grid-item-top">
@@ -190,12 +194,80 @@
</div>
</a>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-click="showAppConfigure(app, 'updates')" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
</div>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-click="showAppConfigure(app, 'updates')" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')" uib-tooltip="Update Available">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
<div class="app-checklist-badge" ng-click="showAppConfigure(app, 'info')" ng-show="pendingChecklistItems(app)">
{{ pendingChecklistItems(app) }}
</div>
</div>
</div>
<div class="app-list card card-large" ng-show="view === VIEWS.LIST">
<table class="table table-hover" style="margin: 0;">
<thead>
<tr>
<th style="width: 32px" class="hand" ng-click="setOrderBy('status')"><i ng-show="orderBy === 'status'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 32px">&nbsp;</th>
<th style="width: 35%" class="hand" ng-click="setOrderBy('location')">{{ 'app.display.label' | tr }} <i ng-show="orderBy === 'location'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 30%" class="hand hide-mobile" ng-click="setOrderBy('app')">App Title<i ng-show="orderBy === 'app'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 32px">&nbsp;</th>
<th style="width: 32px" class="hand hide-mobile text-center" ng-click="setOrderBy('sso')"><i class="fas fa-user-lock"></i> <i ng-show="orderBy === 'sso'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width:160px" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr class="app-list-item" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter:orderByReverse" uib-tooltip="{{ app | appProgressMessage }}">
<td class="elide-table-cell">
<i class="fa fa-circle" ng-class="app | installationStateClass" uib-tooltip="{{ app | installationStateLabel }}"></i>
</td>
<td class="elide-table-cell app-list-app-link-cell">
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank" class="app-list-app-link">
<img ng-src="{{ app.iconUrl || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)" class="app-list-item-icon"/>
</a>
</td>
<td class="elide-table-cell app-list-app-link-cell">
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank" class="app-list-app-link">
<span style="font-size: 16px;">{{ app.label || app.subdomain || app.fqdn }}</span><br/>
<span class="text-muted text-small">{{ app.fqdn.indexOf('http') === 0 ? app.fqdn : 'https://'+app.fqdn }}</span>
</a>
</td>
<td class="elide-table-cell hide-mobile">{{ app.manifest.title || 'App Link' }}</td>
<td class="elide-table-cell hide-mobile text-center">
<a class="badge badge-danger" ng-show="pendingChecklistItems(app)" ng-href="#/app/{{ app.id}}/info">{{ pendingChecklistItems(app) }}</a>
</td>
<td class="elide-table-cell hide-mobile text-center">
<div ng-show="app.type !== APP_TYPES.LINK">
<i class="fa-brands fa-openid" ng-show="app.ssoAuth && app.manifest.addons.oidc" uib-tooltip="{{ 'apps.auth.openid' | tr }}"></i>
<i class="fas fa-user" ng-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" uib-tooltip="{{ 'apps.auth.sso' | tr }}"></i>
<i class="far fa-user" ng-show="!app.ssoAuth && !app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.nosso' | tr }}"></i>
<i class="fas fa-envelope" ng-show="app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.email' | tr }}"></i>
</div>
</td>
<td class="elide-table-cell text-right">
<span ng-show="isOperator(app)">
<a class="btn btn-xs btn-success" style="padding: 1px 7px;" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')" ng-href="#/app/{{ app.id}}/updates" uib-tooltip="Update Available"><i class="fa fa-arrow-up"></i></a>
<div class="btn-group btn-group-xs" role="group">
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.LINK" ng-href="{{ '/frontend/logs.html?appId=' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}"><i class="fas fa-align-left"></i></a>
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.PROXIED && app.type !== APP_TYPES.LINK" ng-href="{{ '/frontend/terminal.html?id=' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}"><i class="fa fa-terminal"></i></a>
<a class="btn btn-xs btn-default" ng-show="app.manifest.addons.localstorage" ng-href="{{ '/frontend/filemanager.html#/home/app/' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
</div>
<button class="btn btn-xs btn-default" ng-show="app.type === APP_TYPES.LINK" ng-click="applinksEdit.show(app)" uib-tooltip="Configure Applink"><i class="fa fa-cog"></i></button>
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.LINK" ng-href="#/app/{{ app.id}}/info" uib-tooltip="Configure App"><i class="fa fa-cog"></i></a>
</span>
</td>
</tr>
</tbody>
</table>
<br/>
<div>
{{ 'apps.apps.count' | tr:{ count: (installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch).length } }}
</div>
</div>
</div>
+80 -14
View File
@@ -4,6 +4,7 @@
/* global $:false */
/* global APP_TYPES */
/* global onAppClick */
/* global localStorage, document, FileReader */
angular.module('Application').controller('AppsController', ['$scope', '$translate', '$interval', '$location', 'Client', function ($scope, $translate, $interval, $location, Client) {
var ALL_DOMAINS_DOMAIN = { _alldomains: true, domain: 'All Domains' }; // dummy record for the single select filter
@@ -32,6 +33,15 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.showFilter = false;
$scope.filterActive = false;
$scope.VIEWS = {
GRID: 'grid',
LIST: 'list'
};
$scope.view = $scope.VIEWS.GRID;
$scope.orderBy = 'location'; // or app, status, sso
$scope.orderByReverse = false;
$scope.allUsers = [];
$scope.allGroups = [];
@@ -45,11 +55,59 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
if (tr['app.states.updateAvailable']) $scope.states[3].label = tr['app.states.updateAvailable'];
});
// for sorting of the app grid items
$scope.labelOrFQDN = function (item) {
$scope.pendingChecklistItems = function (app) {
if (!app.checklist) return 0;
return Object.keys(app.checklist).filter(function (key) { return !app.checklist[key].acknowledged; }).length;
};
$scope.setOrderBy = function (by) {
if (by === $scope.orderBy) {
$scope.orderByReverse = !$scope.orderByReverse;
} else {
$scope.orderBy = by;
$scope.orderByReverse = false;
}
localStorage.appsOrderBy = by;
if ($scope.orderByReverse) localStorage.appsOrderByReverse = true;
else localStorage.removeItem('appsOrderByReverse');
};
// for sorting/grouping
$scope.orderByFilter = function (item) {
if ($scope.orderBy === 'app') return item.manifest.title || 'App Link';
if ($scope.orderBy === 'status') return item.installationState + '-' + item.runState;
if ($scope.orderBy === 'sso') return item.sso;
return item.label || item.fqdn;
};
$scope.setView = function (view) {
if (view !== $scope.VIEWS.LIST && view !== $scope.VIEWS.GRID) return;
$scope.view = view;
localStorage.appsView = view;
};
$scope.toggleView = function () {
$scope.view = $scope.view === $scope.VIEWS.GRID ? $scope.VIEWS.LIST : $scope.VIEWS.GRID;
localStorage.appsView = $scope.view;
};
$scope.toggleFilter = function () {
$scope.showFilter = !$scope.showFilter;
if ($scope.showFilter) localStorage.appsShowFilter = true;
else localStorage.removeItem('appsShowFilter');
// clear on hide
if (!$scope.showFilter) {
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
}
};
$scope.$watch('selectedTags', function (newVal, oldVal) {
if (newVal === oldVal) return;
@@ -79,22 +137,13 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.onAppClick = function (app, $event) { onAppClick(app, $event, $scope.isOperator(app), $scope); };
$scope.clearAllFilter = function () {
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
};
$scope.appPostInstallConfirm = {
app: {},
message: '',
confirmed: false,
show: function (app) {
$scope.appPostInstallConfirm.app = app;
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
$scope.appPostInstallConfirm.confirmed = false;
$('#appsPostInstallConfirmModal').modal('show');
@@ -102,8 +151,6 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
},
submit: function () {
if (!$scope.appPostInstallConfirm.confirmed) return;
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
@@ -256,6 +303,12 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
});
});
$scope.setView(localStorage.appsView);
$scope.orderBy = localStorage.appsOrderBy || 'location';
$scope.orderByReverse = !!localStorage.appsOrderByReverse;
$scope.showFilter = !!localStorage.appsShowFilter;
if (!$scope.user.isAtLeastAdmin) return;
// load local settings and apply tag filter
@@ -305,7 +358,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
// setup all the dialog focus handling
['applinksAddModal', 'applinksEditModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
$(this).find('autofocus]:first').focus();
});
});
@@ -316,4 +369,17 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
});
$('.modal-backdrop').remove();
function keyboardHandler(event) {
if (event.key === '/') {
document.getElementById('appSearch').focus();
event.preventDefault();
}
}
document.addEventListener('keydown', keyboardHandler);
$scope.$on('$destroy', function () {
document.removeEventListener('keydown', keyboardHandler);
});
}]);
+5 -5
View File
@@ -69,17 +69,17 @@
</div>
<div class="has-error text-center" ng-show="appInstall.error.port">{{ appInstall.error.port }}</div>
<div ng-repeat="(env, info) in appInstall.portBindingsInfo">
<div ng-repeat="(env, info) in appInstall.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!appInstallForm.itemName{{$index}}.$dirty && appInstall.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portBindingsEnabled[env]">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
<input type="number" class="form-control" ng-model="appInstall.portBindings[env]" ng-disabled="!appInstall.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="appInstall.ports[env]" ng-disabled="!appInstall.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="appInstall.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -407,7 +407,7 @@
<center>
<a href="" ng-click="appstoreLogin.setupType = 'signup'" ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.setupType = 'login'" ng-show="appstoreLogin.setupType === 'signup' || appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">use a setup token</a></span>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">Use a setup token</a></span>
</center>
</div>
</div>
+18 -17
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false */
/* global angular:false, document, window, localStorage, FileReader */
/* global $:false */
/* global async */
/* global ERROR */
@@ -10,7 +10,7 @@
angular.module('Application').controller('AppStoreController', ['$scope', '$translate', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $translate, $location, $timeout, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.HOST_PORT_MIN = 1024;
$scope.HOST_PORT_MIN = 1;
$scope.HOST_PORT_MAX = 65535;
$scope.ready = false;
@@ -122,7 +122,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: '',
domain: null, // object and not the string
secondaryDomains: {},
portBindings: {},
ports: {},
portsEnabled: {},
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
@@ -147,7 +148,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appInstall.subdomain = '';
$scope.appInstall.domain = null;
$scope.appInstall.secondaryDomains = {};
$scope.appInstall.portBindings = {};
$scope.appInstall.ports = {};
$scope.appInstall.state = 'appInfo';
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
@@ -176,13 +177,13 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
var app = $scope.appInstall.app;
var DEFAULT_MEMORY_LIMIT = 1024 * 1024 * 256;
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM+Swap
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM
var used = Client.getInstalledApps().reduce(function (prev, cur) {
if (cur.runState === RSTATES.STOPPED) return prev;
return prev + (cur.memoryLimit || cur.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT);
}, 0);
var totalMemory = ($scope.memory.memory + $scope.memory.swap) * 1.5;
var totalMemory = $scope.memory.memory * 2;
var available = (totalMemory || 0) - used;
var enoughResourcesAvailable = (available - needed) >= 0;
@@ -218,9 +219,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
$scope.appInstall.portBindingsInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
$scope.appInstall.portInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.ports = {}; // This holds the env:port pair
$scope.appInstall.portsEnabled = {}; // This holds the enabled/disabled flag
var manifest = app.manifest;
$scope.appInstall.optionalSso = !!manifest.optionalSso;
@@ -232,8 +233,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
// set default ports
var allPorts = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts);
for (var env in allPorts) {
$scope.appInstall.portBindings[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portBindingsEnabled[env] = true;
$scope.appInstall.ports[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portsEnabled[env] = true;
}
$('#appInstallModal').modal('show');
@@ -253,11 +254,11 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
if ($scope.appInstall.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appInstall.portBindings[env];
// only use enabled ports from ports
var finalPorts = {};
for (var env in $scope.appInstall.ports) {
if ($scope.appInstall.portsEnabled[env]) {
finalPorts[env] = $scope.appInstall.ports[env];
}
}
@@ -273,7 +274,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: $scope.appInstall.subdomain || '',
domain: $scope.appInstall.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
ports: finalPorts,
accessRestriction: finalAccessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
+25 -25
View File
@@ -32,8 +32,10 @@
</div>
<br/>
<p class="text-muted">{{ 'backups.backupDetails.list' | tr:{ appCount: backupDetails.backup.contents.length } }}:</p>
<span ng-repeat="app in backupDetails.backup.contents | orderBy:['label','fqdn']">
<a ng-href="/#/app/{{app.id}}/backups">{{ app.label || app.fqdn }}</a><span ng-hide="$last">,</span>
<span ng-repeat="content in backupDetails.backup.contents | orderBy:['label','fqdn']">
<a ng-if="content.fqdn" ng-href="/#/app/{{content.id}}/backups">{{ content.label || content.fqdn }}</a>
<a ng-if="!content.fqdn" ng-href="/#/eventlog?search={{content.id}}">{{ content.id }}</a>
<span ng-hide="$last">,</span>
</span>
</div>
<div class="modal-footer">
@@ -388,48 +390,46 @@
</div>
<a href="" ng-click="configureBackup.advancedVisible = true" ng-hide="configureBackup.advancedVisible">{{ 'backups.configureBackupStorage.advancedSettings' | tr }}</a>
<div uib-collapse="!configureBackup.advancedVisible">
<div uib-collapse="!configureBackup.advancedVisible">
<div class="form-group">
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
<label class="control-label" for="sliderConfigureBackupMemoryLimit">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.memoryLimitDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" tooltip="hide" step="268435456" ticks="configureBackup.memoryTicks"></slider>
</div>
<input type="range" id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" step="{{ 256*1024*1024 }}" min="{{ MIN_MEMORY_LIMIT }}" max="{{ MAX_MEMORY_LIMIT }}" />
</div>
<div class="form-group" ng-show="s3like(configureBackup.provider)">
<label class="control-label">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyBinarySize:'Default (50 MiB)' }}</b></label>
<label class="control-label" for="sliderConfigureBackupUploadPartSize">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyBinarySize:'Default (50 MiB)' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.uploadPartSizeDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupUploadPartSize" ng-model="configureBackup.uploadPartSize" step="1048576" tooltip="hide" ticks="configureBackup.uploadPartSizeTicks" ticks-snap-bounds="2097152"></slider>
</div>
<input type="range" id="sliderConfigureBackupUploadPartSize" ng-model="configureBackup.uploadPartSize" list="uploadPartSizeTicks" step="{{ 1024*1024 }}" min="{{ 1024*1024 }}" max="{{ 1024*1024*1024 }}" />
<datalist id="uploadPartSizeTicks">
<option value="{{ 1024*1024 }}"></option>
<option value="{{ 64*1024*1024 }}"></option>
<option value="{{ 128*1024*1024 }}"></option>
<option value="{{ 256*1024*1024 }}"></option>
<option value="{{ 512*1024*1024 }}"></option>
<option value="{{ 1024*1024*1024 }}"></option>
</datalist>
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && configureBackup.provider !== 'noop'">
<label class="control-label">{{ 'backups.configureBackupStorage.uploadConcurrency' | tr }}: <b>{{ configureBackup.syncConcurrency }}</b></label>
<label class="control-label" for="sliderConfigureBackupSyncConcurrency">{{ 'backups.configureBackupStorage.uploadConcurrency' | tr }}: <b>{{ configureBackup.syncConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.uploadConcurrencyDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupSyncConcurrency" ng-model="configureBackup.syncConcurrency" tooltip="hide" min="10" max="200" step="10"></slider>
</div>
<input type="range" id="sliderConfigureBackupSyncConcurrency" ng-model="configureBackup.syncConcurrency" step="10" min="10" max="200" />
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label">{{ 'backups.configureBackupStorage.downloadConcurrency' | tr }}: <b>{{ configureBackup.downloadConcurrency }}</b></label>
<label class="control-label" for="sliderConfigureBackupDownloadConcurrency">{{ 'backups.configureBackupStorage.downloadConcurrency' | tr }}: <b>{{ configureBackup.downloadConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.downloadConcurrencyDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.downloadConcurrency" tooltip="hide" min="10" max="200" step="10"></slider>
</div>
<input type="range" id="sliderConfigureBackupDownloadConcurrency" ng-model="configureBackup.downloadConcurrency" step="10" min="10" max="200" />
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label">{{ 'backups.configureBackupStorage.copyConcurrency' | tr }}: <b>{{ configureBackup.copyConcurrency }}</b></label>
<label class="control-label" for="sliderConfigureBackupCopyConcurrency">{{ 'backups.configureBackupStorage.copyConcurrency' | tr }}: <b>{{ configureBackup.copyConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.copyConcurrencyDescription' | tr }}
<span ng-show="configureBackup.provider === 'digitalocean-spaces'">{{ 'backups.configureBackupStorage.copyConcurrencyDigitalOceanNote' | tr }}</span>
</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.copyConcurrency" tooltip="hide" min="10" max="500" step="10"></slider>
</div>
<input type="range" id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.copyConcurrency" step="10" min="10" max="500" />
</div>
</div> <!-- advanced -->
@@ -476,7 +476,7 @@
<span>{{ prettyProviderName(backupConfig.provider) }}</span>
</div>
</div>
<div class="row">
<div class="row" ng-show="backupConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.location.location' | tr }}</span>
</div>
@@ -605,7 +605,7 @@
<tr ng-repeat="backup in backups">
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<td ng-click="backupDetails.show(backup)" class="hand">v{{ backup.packageVersion }}</td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td ng-click="backupDetails.show(backup)" class="hand">
<span ng-show="!backup.contents.length">{{ 'backups.listing.noApps' | tr }}</span>
<span ng-show="backup.contents.length">{{ 'backups.listing.appCount' | tr:{ appCount: backup.contents.length } }}</span>
+29 -29
View File
@@ -8,6 +8,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.SECRET_PLACEHOLDER = SECRET_PLACEHOLDER;
$scope.MIN_MEMORY_LIMIT = 1024 * 1024 * 1024; // 1 GB
$scope.MAX_MEMORY_LIMIT = $scope.MIN_MEMORY_LIMIT; // set later
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
@@ -463,7 +464,6 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
memoryTicks: [],
memoryLimit: $scope.MIN_MEMORY_LIMIT,
uploadPartSizeTicks: [],
uploadPartSize: 50 * 1024 * 1024,
copyConcurrency: '',
downloadConcurrency: '',
@@ -477,7 +477,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
username: '',
password: '',
diskPath: '',
seal: false,
seal: true,
user: '',
port: 22,
privateKey: ''
@@ -506,7 +506,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.copyConcurrency = $scope.configureBackup.provider === 's3' ? 500 : 10;
$scope.configureBackup.disk = null;
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: false, user: '', port: 22, privateKey: '' };
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
},
show: function () {
@@ -540,25 +540,14 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks;
$scope.configureBackup.chown = $scope.backupConfig.chown;
var limits = $scope.backupConfig.limits || {};
$scope.configureBackup.memoryLimit = Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT);
const limits = $scope.backupConfig.limits || {};
$scope.configureBackup.memoryLimit = limits.memoryLimit ? Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT) : $scope.MIN_MEMORY_LIMIT;
$scope.configureBackup.uploadPartSize = limits.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024);
$scope.configureBackup.downloadConcurrency = limits.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10);
$scope.configureBackup.syncConcurrency = limits.syncConcurrency || ($scope.backupConfig.provider === 's3' ? 20 : 10);
$scope.configureBackup.copyConcurrency = limits.copyConcurrency || ($scope.backupConfig.provider === 's3' ? 500 : 10);
var totalMemory = Math.max(($scope.memory.memory + $scope.memory.swap) * 1.5, 2 * 1024 * 1024);
$scope.configureBackup.memoryTicks = [ $scope.MIN_MEMORY_LIMIT ];
for (var i = 1024; i <= totalMemory/1024/1024; i *= 2) {
$scope.configureBackup.memoryTicks.push(i * 1024 * 1024);
}
$scope.configureBackup.uploadPartSizeTicks = [ 5 * 1024 * 1024 ];
for (var j = 32; j <= 1 * 1024; j *= 2) { // 5 GB is max for s3. but let's keep things practical for now. we upload 3 parts in parallel
$scope.configureBackup.uploadPartSizeTicks.push(j * 1024 * 1024);
}
var mountOptions = $scope.backupConfig.mountOptions || {};
$scope.configureBackup.mountOptions = {
host: mountOptions.host || '',
@@ -607,7 +596,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
schedulePattern: $scope.backupConfig.schedulePattern,
retentionPolicy: $scope.backupConfig.retentionPolicy,
limits: {
memoryLimit: $scope.configureBackup.memoryLimit,
memoryLimit: parseInt($scope.configureBackup.memoryLimit),
},
};
if ($scope.configureBackup.password) {
@@ -663,6 +652,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
backupConfig.limits.uploadPartSize = parseInt($scope.configureBackup.uploadPartSize);
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.configureBackup.bucket;
backupConfig.prefix = $scope.configureBackup.prefix;
@@ -713,12 +704,10 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks;
}
backupConfig.limits.uploadPartSize = $scope.configureBackup.uploadPartSize;
if (backupConfig.format === 'rsync') {
backupConfig.limits.downloadConcurrency = $scope.configureBackup.downloadConcurrency;
backupConfig.limits.syncConcurrency = $scope.configureBackup.syncConcurrency;
backupConfig.limits.copyConcurrency = $scope.configureBackup.copyConcurrency;
backupConfig.limits.downloadConcurrency = parseInt($scope.configureBackup.downloadConcurrency);
backupConfig.limits.syncConcurrency = parseInt($scope.configureBackup.syncConcurrency);
backupConfig.limits.copyConcurrency = parseInt($scope.configureBackup.copyConcurrency);
}
Client.setBackupConfig(backupConfig, function (error) {
@@ -766,7 +755,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.error.backupFolder = true;
}
} else {
console.error('Unable to change provider.', error);
$scope.configureBackup.error.generic = error.message;
}
return;
@@ -794,14 +783,23 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
});
$scope.backups.forEach(function (backup) {
backup.contents = [];
backup.contents = []; // { id, label, fqdn }
backup.dependsOn.forEach(function (appBackupId) {
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return;
if (match[1].indexOf('.') !== -1) { // newer backups have fqdn in them
if (appsByFqdn[match[1]]) backup.contents.push(appsByFqdn[match[1]]);
if (!match) return; // for example, 'mail'
const app = appsById[match[1]];
if (app) {
backup.contents.push({
id: app.id,
label: app.label,
fqdn: app.fqdn
});
} else {
if (appsById[match[1]]) backup.contents.push(appsById[match[1]]);
backup.contents.push({
id: match[1],
label: null,
fqdn: null
});
}
});
});
@@ -850,6 +848,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if (error) console.error(error);
$scope.memory = memory;
var nearestGb = Math.ceil($scope.memory.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
$scope.MAX_MEMORY_LIMIT = nearestGb;
fetchBackups();
getBackupConfig();
@@ -887,7 +887,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
// setup all the dialog focus handling
['configureBackupModal', 'editBackupModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
$(this).find('[autofocus]:first').focus();
});
});
+34 -22
View File
@@ -1,27 +1,27 @@
<!-- Modal change avatar -->
<div class="modal fade" id="avatarChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'branding.changeLogo.title' | tr }}</h4>
</div>
<div class="modal-body branding-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{ avatarChange.avatarUrl() }}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
<br/>
<br/>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.setAvatar()"> {{ 'main.dialog.save' | tr }}</button>
</div>
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'branding.changeLogo.title' | tr }}</h4>
</div>
<div class="modal-body branding-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{ avatarChange.avatarUrl() }}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
<br/>
<br/>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.setAvatar()"> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
@@ -49,7 +49,19 @@
</div>
<div class="branding-avatar" ng-click="avatarChange.showChangeAvatar()">
<img ng-src="{{ about.avatarUrl() }}"/>
<div class="overlay"></div>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
</div>
<div class="form-group">
<div>
<label class="control-label">{{ 'branding.backgroundImage' | tr }}</label>
<div class="branding-background" ng-click="background.selectNew()">
<img ng-src="{{ background.url() }}" onerror="this.src = '/img/background-image-placeholder.svg'"/>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" ng-show="!background.cleared" ng-click="background.clear()">{{ 'branding.clearBackgroundImage' | tr }}</a>
<input type="file" id="backgroundFileInput" style="display: none" accept="image/*"/>
</div>
</div>
@@ -61,7 +73,7 @@
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="about.submit()" ng-disabled="(!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"><i class="fa fa-circle-notch fa-spin" ng-show="about.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="about.submit()" ng-disabled="false && (!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"><i class="fa fa-circle-notch fa-spin" ng-show="about.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
+98 -8
View File
@@ -134,6 +134,76 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
fr.readAsDataURL(event.target.files[0]);
};
$scope.background = {
enabled: false,
file: null,
src: null,
cleared: false,
newImageFile: null,
cacheBusting: Date.now(),
url() {
if ($scope.background.cleared) return '/img/background-image-placeholder.svg';
else if ($scope.background.src) return $scope.background.src;
else return `${Client.apiOrigin}/api/v1/cloudron/background?${$scope.background.cacheBusting}`;
},
selectNew() {
document.getElementById('backgroundFileInput').click();
},
submit(callback) {
if ($scope.background.cleared) {
Client.changeCloudronBackground(null, callback);
} else if ($scope.background.newImageFile) {
Client.changeCloudronBackground($scope.background.newImageFile, callback);
} else {
callback();
}
},
clear() {
$scope.background.cleared = true;
}
};
document.getElementById('backgroundFileInput').onchange = function (event) {
const fr = new FileReader();
fr.onload = function () {
const image = new Image();
image.onload = function () {
// convert and scale to webp max 4k
const maxWidth = 4096;
const canvas = document.createElement('canvas');
if (image.naturalWidth > maxWidth) {
canvas.width = maxWidth;
canvas.height = (image.naturalHeight / image.naturalWidth) * maxWidth;
} else {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
}
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
$scope.$apply(function () {
const myImage = new File([blob], 'background.webp', { type: blob.type });
$scope.background.cleared = false;
$scope.background.newImageFile = myImage;
$scope.background.src = URL.createObjectURL(myImage);
});
}, 'image/webp');
$scope.background.file = event.target.files[0];
};
image.src = fr.result;
};
fr.readAsDataURL(event.target.files[0]);
};
$scope.about = {
busy: false,
error: {},
@@ -141,7 +211,7 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
avatar: null,
avatarBlob: null,
avatarUrl: function () {
avatarUrl() {
if ($scope.about.avatar) {
return $scope.about.avatar.data || $scope.about.avatar.url;
} else {
@@ -149,12 +219,22 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
}
},
refresh: function () {
refresh() {
$scope.about.cloudronName = $scope.config.cloudronName;
$scope.about.avatar = null;
Client.hasCloudronBackground(function (error, result) {
if (error) return console.error('Failed to get background state.', error);
$scope.background.enabled = result;
$scope.background.file = null;
$scope.background.src = null;
$scope.background.newImageFile = null;
$scope.background.cacheBusting = Date.now();
});
},
submit: function () {
submit() {
$scope.about.error.name = null;
$scope.about.busy = true;
@@ -182,12 +262,22 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
return;
}
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
$scope.background.submit(function (error) {
if (error) {
$scope.about.busy = false;
console.error('Unable to change background.', error);
return;
}
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.busy = false;
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.refresh();
$scope.about.busy = false;
});
});
});
});
+6
View File
@@ -183,6 +183,12 @@
<input type="text" class="form-control" ng-model="domainConfigure.vultrToken" name="vultrToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'vultr'">
</div>
<!-- deSEC -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'desec'">
<label class="control-label">{{ 'domains.domainDialog.deSecToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.deSecToken" name="deSecToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'desec'">
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecom'">
<label class="control-label">{{ 'domains.domainDialog.nameComUsername' | tr }}</label>
+8 -1
View File
@@ -41,11 +41,12 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Custom Wildcard Certificate', value: 'fallback' },
];
// keep in sync with setupdns.js
// keep in sync with setup.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'deSEC', value: 'desec' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
@@ -69,6 +70,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
case 'bunny': return 'Bunny';
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare';
case 'desec': return 'deSEC';
case 'digitalocean': return 'DigitalOcean';
case 'dnsimple': return 'dnsimple';
case 'gandi': return 'Gandi LiveDNS';
@@ -258,6 +260,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
deSecToken: '',
nameComToken: '',
nameComUsername: '',
namecheapUsername: '',
@@ -321,6 +324,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.dnsimpleAccessToken = domain.provider === 'dnsimple' ? domain.config.accessToken : '';
$scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : '';
$scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : '';
$scope.domainConfigure.deSecToken = domain.provider === 'desec' ? domain.config.token : '';
$scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : '';
$scope.domainConfigure.cloudflareToken = domain.provider === 'cloudflare' ? domain.config.token : '';
$scope.domainConfigure.cloudflareEmail = domain.provider === 'cloudflare' ? domain.config.email : '';
@@ -402,6 +406,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
data.token = $scope.domainConfigure.hetznerToken;
} else if (provider === 'vultr') {
data.token = $scope.domainConfigure.vultrToken;
} else if (provider === 'desec') {
data.token = $scope.domainConfigure.deSecToken;
} else if (provider === 'gandi') {
data.token = $scope.domainConfigure.gandiApiKey;
} else if (provider === 'godaddy') {
@@ -503,6 +509,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.porkbunApikey = '';
$scope.domainConfigure.porkbunSecretapikey = '';
$scope.domainConfigure.vultrToken = '';
$scope.domainConfigure.deSecToken = '';
$scope.domainConfigure.tlsConfig.provider = 'letsencrypt-prod';
$scope.domainConfigure.zoneName = '';
+53 -107
View File
@@ -108,78 +108,74 @@
<h4 class="modal-title">{{ 'email.editMailboxDialog.title' | tr:{ name: mailboxes.edit.name, domain: domain.domain } }}</h4>
</div>
<div class="modal-body">
<form name="mailboxedit_form" role="form" ng-submit="mailboxes.edit.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group">
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
<div class="control-label">
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<div class="form-group">
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
<div class="control-label">
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
<div class="form-group aliases">
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="form-group aliases">
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control input-sm" ng-model="alias.name" autofocus>
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control input-sm" ng-model="alias.name">
<div class="input-group-btn">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="incomingDomain in incomingDomains">
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
</li>
</ul>
</div>
<div class="input-group-btn">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="incomingDomain in incomingDomains">
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
</li>
</ul>
</div>
</div>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
</div>
</div>
<div ng-show="mailboxes.edit.aliases.length === 0">
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
</div>
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
</div>
</div>
<div class="form-group">
<label for="storageQuota">
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<div style="padding: 0 10px;">
<slider id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" ticks-snap-bounds="1000000000" tooltip="hide" ticks="storageQuotaTicks"></slider>
</div>
<div ng-show="mailboxes.edit.aliases.length === 0">
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
</label>
</div>
<div class="form-group">
<label for="storageQuota">
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
<datalist id="storageQuotaTicks">
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
</datalist>
</div>
<input class="hide" type="submit" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"/>
</form>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -209,44 +205,6 @@
</div>
</div>
<!-- Modal import mailboxes -->
<div class="modal fade" id="mailboxImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'email.mailboxImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!mailboxImport.done">
<div ng-show="!mailboxImport.busy">
<p ng-bind-html=" 'email.mailboxImportDialog.description' | tr:{ docsLink: 'https://cloudron.io/documentation/email/#import-mailboxes' } "></p>
<input type="file" style="display: none;" id="mailboxImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="mailboxImport.openFileInput()">{{ 'email.mailboxImportDialog.fileInput' | tr }}</button>
<br/>
<br/>
<p class="text-danger" ng-show="mailboxImport.error.file">{{ mailboxImport.error.file }}</p>
<p class="text-info" ng-show="mailboxImport.mailboxes.length">{{ 'email.mailboxImportDialog.mailboxesFound' | tr:{ count: mailboxImport.mailboxes.length } }}</p>
</div>
<div ng-show="mailboxImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailboxImport.percent }}%"></div>
</div>
</div>
<div ng-show="mailboxImport.done">
<p>{{ 'email.mailboxImportDialog.success' | tr:{ count: mailboxImport.success } }}</p>
<div ng-show="mailboxImport.error.import.length">
<p class="text-danger">{{ 'email.mailboxImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in mailboxImport.error.import"><b>{{ tmp.mailbox.name }}@{{ tmp.mailbox.domain }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="mailboxImport.import()" ng-show="!mailboxImport.done" ng-disabled="mailboxImport.busy || !mailboxImport.mailboxes.length"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxImport.busy"></i> {{ 'email.mailboxImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal add mailinglist -->
<div class="modal fade" id="mailinglistAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -385,7 +343,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="https://docs.cloudron.io/email/" target="_blank">{{ 'app.docsAction' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="howToConnectInfo.show()">{{ 'email.config.clientConfiguration' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="domain.mailConfig.enabled ? howToConnectInfo.show() : null">{{ 'email.config.clientConfiguration' | tr }}</a></li>
</ul>
</div>
</h3>
@@ -416,18 +374,6 @@
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="btn-group pull-right" style="margin-left: 5px;">
<button class="btn btn-default" ng-click="mailboxImport.show()" uib-tooltip="{{ 'email.incoming.mailboxes.importTooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'email.incoming.mailboxes.exportTooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="mailboxExport('csv')">{{ 'email.incoming.mailboxes.mailboxExport.csv' | tr }}</a></li>
<li><a href="" ng-click="mailboxExport('json')">{{ 'email.incoming.mailboxes.mailboxExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
</h3>
</div>
+2 -168
View File
@@ -42,7 +42,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$scope.domain = null;
$scope.adminDomain = null;
$scope.mailUsage = null;
$scope.storageQuotaTicks = [ 500*1000*1000, 1*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
$scope.storageQuotaTicks = [ 500*1000*1000, 5*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
$scope.expectedDnsRecords = {
mx: { },
@@ -377,172 +377,6 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
};
$scope.mailboxImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
mailboxes: [],
reset: function () {
$scope.mailboxImport.busy = false;
$scope.mailboxImport.error = null;
$scope.mailboxImport.mailboxes = [];
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
},
handleFileChanged: function () {
$scope.mailboxImport.reset();
var fileInput = document.getElementById('mailboxImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.mailboxImport.mailboxes = [];
var mailboxes = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.mailboxImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 4) {
$scope.mailboxImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 4' };
return;
}
mailboxes.push({
name: items[0].trim(),
domain: items[1].trim(),
owner: items[2].trim(),
ownerType: items[3].trim(),
});
}
} else {
try {
mailboxes = JSON.parse(reader.result).map(function (mailbox) {
return {
name: mailbox.name,
domain: mailbox.domain,
owner: mailbox.owner,
ownerType: mailbox.ownerType
};
});
} catch (e) {
console.error('Failed to parse mailboxes.', e);
$scope.mailboxImport.error = { file: 'Imported file is not valid JSON' };
}
}
$scope.mailboxImport.mailboxes = mailboxes;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.mailboxImport.reset();
// named so no duplactes
document.getElementById('mailboxImportFileInput').addEventListener('change', $scope.mailboxImport.handleFileChanged);
$('#mailboxImportModal').modal('show');
},
openFileInput: function () {
$('#mailboxImportFileInput').click();
},
import: function () {
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
$scope.mailboxImport.error = { import: [] };
$scope.mailboxImport.busy = true;
var processed = 0;
async.eachSeries($scope.mailboxImport.mailboxes, function (mailbox, callback) {
var owner = $scope.owners.find(function (o) { return o.display === mailbox.owner && o.type === mailbox.ownerType; }); // owner may not exist
if (!owner) {
$scope.mailboxImport.error.import.push({ error: new Error('Could not detect owner'), mailbox: mailbox });
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
return callback();
}
Client.addMailbox(mailbox.domain, mailbox.name, owner.id, mailbox.ownerType, function (error) {
if (error) $scope.mailboxImport.error.import.push({ error: error, mailbox: mailbox });
else ++$scope.mailboxImport.success;
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.mailboxImport.busy = false;
$scope.mailboxImport.done = true;
if ($scope.mailboxImport.success) $scope.mailboxes.refresh();
});
}
};
$scope.mailboxExport = function (type) {
// FIXME only does first 10k mailboxes
Client.listMailboxes($scope.domain.domain, '', 1, 10000, function (error, result) {
if (error) {
Client.error('Failed to list mailboxes. Full error in the webinspector.');
return console.error('Failed to list mailboxes.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
return {
name: mailbox.name,
domain: mailbox.domain,
owner: owner ? owner.display : '', // this meta property is set when we get the user list
ownerType: owner ? owner.type : '',
active: mailbox.active,
aliases: mailbox.aliases
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
var aliases = mailbox.aliases.map(function (a) { return a.name + '@' + a.domain; }).join(' ');
return [ mailbox.name, mailbox.domain, owner ? owner.display : '', owner ? owner.type : '', aliases, mailbox.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = $scope.domain.domain.replaceAll('.','_') + '-mailboxes.' + type;
document.body.appendChild(a);
a.click();
});
};
$scope.mailboxes = {
mailboxes: [],
search: '',
@@ -639,7 +473,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
ownerType: $scope.mailboxes.edit.owner.type,
active: $scope.mailboxes.edit.active,
enablePop3: $scope.mailboxes.edit.enablePop3,
storageQuota: $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0,
storageQuota: $scope.mailboxes.edit.storageQuotaEnabled ? parseInt($scope.mailboxes.edit.storageQuota) : 0,
messagesQuota: 0
};
+6 -6
View File
@@ -10,8 +10,8 @@
<br>
<form name="maxEmailSizeChangeForm" role="form" novalidate ng-submit="maxEmailSize.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label">{{ 'emails.changeMailSizeDialog.size' | tr }} <b>{{ maxEmailSize.size | prettyDecimalSize }}</b></label>
<slider ng-model="maxEmailSize.size" tooltip="hide" min="1000000" max="1000000000" step="1000000"></slider>
<label class="control-label" for="maxEmailSizeInput">{{ 'emails.changeMailSizeDialog.size' | tr }} <b>{{ maxEmailSize.size | prettyDecimalSize }}</b></label>
<input type="range" id="maxEmailSizeInput" ng-model="maxEmailSize.size" step="1000000" min="1000000" max="1000000000" />
</div>
<input class="ng-hide" type="submit"/>
</form>
@@ -101,8 +101,8 @@
<div class="form-group">
<label class="control-label">{{ 'emails.spamFilterDialog.blacklisteAddresses' | tr }}</label>
<p class="small">{{ 'emails.spamFilterDialog.blacklisteAddressesInfo' | tr }}</p>
<div class="has-error" ng-show="spamConfig.error.blacklist">{{ spamConfig.error.blacklist }}</div>
<textarea ng-model="spamConfig.blacklist" placeholder="{{ 'emails.spamFilterDialog.blacklisteAddressesPlaceholder' | tr }}" name="blacklist" class="form-control" ng-class="{ 'has-error': !spamConfigChangeForm.blacklist.$dirty && spamConfig.error.blacklist }" rows="4"></textarea>
<div class="has-error" ng-show="spamConfig.error.blocklist">{{ spamConfig.error.blocklist }}</div>
<textarea ng-model="spamConfig.blocklist" placeholder="{{ 'emails.spamFilterDialog.blacklisteAddressesPlaceholder' | tr }}" name="blocklist" class="form-control" ng-class="{ 'has-error': !spamConfigChangeForm.blocklist.$dirty && spamConfig.error.blocklist }" rows="4"></textarea>
</div>
<div class="form-group">
<label class="control-label">{{ 'emails.spamFilterDialog.customRules' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#custom-spam-filtering-rules" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
@@ -262,7 +262,7 @@
</h3>
</div>
<div class="card">
<div class="card" ng-show="user.isAtLeastAdmin">
<div class="row">
<div class="col-md-7">
<p ng-bind-html="'emails.changeDomainDialog.description' | tr"></p>
@@ -341,7 +341,7 @@
<span class="text-muted">{{ 'emails.settings.spamFilter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ 'emails.settings.spamFilterOverview' | tr:{ blacklistCount: spamConfig.acl.blacklist.length } }} <a href="" ng-click="spamConfig.show()"><i class="fa fa-edit text-small"></i></a></span>
<span>{{ 'emails.settings.spamFilterOverview' | tr:{ blacklistCount: spamConfig.acl.blocklist.length } }} <a href="" ng-click="spamConfig.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.solrFts' | tr }}</span>
+9 -9
View File
@@ -79,7 +79,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.mailLocation.message = '';
$scope.mailLocation.errorMessage = '';
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error, result) {
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error) {
if (error) {
console.error(error);
$scope.mailLocation.errorMessage = error.message;
@@ -121,7 +121,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
submit: function () {
$scope.maxEmailSize.busy = true;
Client.setMaxEmailSize($scope.maxEmailSize.size, function (error) {
Client.setMaxEmailSize(parseInt($scope.maxEmailSize.size), function (error) {
$scope.maxEmailSize.busy = false;
if (error) return console.error(error);
@@ -250,11 +250,11 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig = {
busy: false,
error: {},
acl: { whitelist: [], blacklist: [] },
acl: { allowlist: [], blocklist: [] },
customConfig: '',
config: '',
blacklist: '', // currently, we don't support whitelist because it requires user to understand a bit more of what he is doing
blocklist: '', // currently, we don't support allowlist because it requires user to understand a bit more of what he is doing
refresh: function () {
Client.getSpamCustomConfig(function (error, config) {
@@ -274,7 +274,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig.busy = false;
$scope.spamConfig.error = {};
$scope.spamConfig.blacklist = $scope.spamConfig.acl.blacklist.join('\n');
$scope.spamConfig.blocklist = $scope.spamConfig.acl.blocklist.join('\n');
$scope.spamConfig.config = $scope.spamConfig.customConfig;
$scope.spamConfigChangeForm.$setUntouched();
@@ -287,13 +287,13 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig.busy = true;
$scope.spamConfig.error = {};
var blacklist = $scope.spamConfig.blacklist.split('\n').filter(function (l) { return l !== ''; });
var blocklist = $scope.spamConfig.blocklist.split('\n').filter(function (l) { return l !== ''; });
Client.setSpamAcl({ blacklist: blacklist, whitelist: [] }, function (error) {
Client.setSpamAcl({ blocklist: blocklist, allowlist: [] }, function (error) {
if (error) {
$scope.spamConfig.busy = false;
$scope.spamConfig.error.blacklist = error.message;
$scope.spamConfigChangeForm.blacklist.$setPristine();
$scope.spamConfig.error.blocklist = error.message;
$scope.spamConfigChangeForm.blocklist.$setPristine();
return;
}
+6 -3
View File
@@ -32,8 +32,8 @@
<thead>
<tr>
<th class="col-md-2">{{ 'eventlog.time' | tr }}</th>
<th class="col-md-3">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-7">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-8">{{ 'eventlog.details' | tr }}</th>
</tr>
</thead>
<tbody ng-repeat="eventLog in eventLogs">
@@ -43,7 +43,10 @@
<td ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="activeEventLog === eventLog">
<td colspan="4"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
<td colspan="4">
<p ng-show="eventLog.raw.source.ip">Source IP: <code>{{ eventLog.raw.source.ip }}</code></p>
<pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre>
</td>
</tr>
</tbody>
</table>
+2
View File
@@ -72,6 +72,7 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'user.remove', value: 'user.remove' },
{ name: 'user.transfer', value: 'user.transfer' },
{ name: 'user.update', value: 'user.update' },
{ name: 'userdirectory.profileconfig.update', value: 'userdirectory.profileconfig.update '},
{ name: 'volume.add', value: 'volume.add' },
{ name: 'volume.update', value: 'volume.update' },
{ name: 'volume.remove', value: 'volume.update' },
@@ -142,6 +143,7 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
};
Client.onReady(function () {
$scope.search = $location.search().search || ''; // sent from the backups view when app is deleted
fetchEventLogs();
});
+1 -1
View File
@@ -171,7 +171,7 @@
</div>
</div>
<div class="row">
<div class="row" ng-show="sysinfo.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
+1
View File
@@ -11,6 +11,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
// keep in sync with sysinfo.js
$scope.sysinfoProvider = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
+2 -2
View File
@@ -395,8 +395,8 @@
<div class="grid-item-top">
<div class="row">
<div class="col-xs-3" style="min-width: 150px;">
<div class="settings-avatar" style="background-image: url('{{ user.avatarUrl }}');">
<div class="overlay" ng-click="avatarChange.showChangeAvatar()"></div>
<div class="settings-avatar" style="background-image: url('{{ user.avatarUrl }}');" ng-click="avatarChange.showChangeAvatar()">
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
</div>
<div class="col-xs-9">
+8 -4
View File
@@ -12,11 +12,14 @@
<div class="form-group">
<label class="control-label" style="display: block;" for="memoryLimit">
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit / 1024 / 1024 + 'MB' }}</b>
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit | prettyBinarySize:'' }}</b>
<button type="button" class="btn btn-xs btn-default pull-right" ng-click="serviceConfigure.resetToDefaults()">{{ 'services.configure.resetToDefaults' | tr }}</button>
</label>
<div style="padding: 0 10px;">
<slider id="memoryLimit" ng-model="serviceConfigure.memoryLimit" step="134217728" tooltip="hide" ticks="serviceConfigure.memoryTicks" ticks-snap-bounds="67108864"></slider>
<input type="range" id="memoryLimit" ng-model="serviceConfigure.memoryLimit" step="134217728" min="{{ serviceConfigure.memoryTicks[0] }}" max="{{ serviceConfigure.memoryTicks[serviceConfigure.memoryTicks.length-1] }}" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option ng-repeat="limit in serviceConfigure.memoryTicks" value="{{ limit }}"></option>
</datalist>
</div>
</div>
@@ -112,9 +115,10 @@
<span ng-show="service.config.memoryLimit">{{ service.config.memoryLimit | prettyBinarySize }}</span>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-disabled="!service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-disabled="service.status === 'disabled' || !service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<!-- restart is always clickable so that a user can rebuild mongodb in disabled state when using VMs where CPU flags can be dynamically changed -->
<button class="btn btn-xs btn-default" ng-click="restartService(service.name)" uib-tooltip="{{ 'services.restartActionTooltip' | tr }}"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': service.status === 'starting' && !service.config.recoveryMode }"></i></button>
<a class="btn btn-xs btn-default" ng-href="{{ '/frontend/logs.html?id=' + service.name }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
<a class="btn btn-xs btn-default" ng-href="{{ service.status === 'disabled' ? '' : ('/frontend/logs.html?id=' + service.name) }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}" ng-disabled="service.status === 'disabled'"><i class="fa fa-file-alt"></i></a>
</td>
</tr>
<tr ng-show="hasRedisServices" ng-click="redisServicesExpanded = !redisServicesExpanded" class="hand">
+18 -11
View File
@@ -20,12 +20,13 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
if (error) return console.log('Error getting status of ' + serviceName + ':' + error.message);
var service = $scope.services.find(function (s) { return s.name === serviceName; });
if (!service) $scope.services[serviceName] = service;
if (!service) callback(new Error('no such service' + serviceName)); // cannot happen
service.status = result.status;
service.config = result.config;
service.memoryUsed = result.memoryUsed;
service.memoryPercent = result.memoryPercent;
service.defaultMemoryLimit = result.defaultMemoryLimit;
callback(null, service);
});
@@ -53,7 +54,10 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
return;
}
if (error) return Client.error(error);
if (error) {
refresh(serviceName);
return Client.error(error);
}
// show "busy" indicator for 3 seconds to show some ui activity
setTimeout(function () { waitForActive(serviceName); }, 3000);
@@ -75,19 +79,22 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.reset();
$scope.serviceConfigure.service = service;
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
$scope.serviceConfigure.recoveryMode = !!service.config.recoveryMode;
$scope.serviceConfigure.memoryTicks = [];
// we max system memory and current service memory for the case where the user configured the service on another server with more resources
var nearest256m = Math.ceil(Math.max($scope.memory.memory, service.config.memoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = service.defaultMemoryLimit;
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
$scope.serviceConfigure.memoryTicks = [];
var npow2 = Math.pow(2, Math.ceil(Math.log($scope.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
$scope.serviceConfigure.memoryTicks.push(i * 1024 * 1024);
for (var i = startTick; i <= nearest256m; i *= 2) {
$scope.serviceConfigure.memoryTicks.push(i);
}
// for firefox widget update
$timeout(function() {
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
}, 500);
$('#serviceConfigureModal').modal('show');
},
@@ -96,7 +103,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.error = null;
var data = {
memoryLimit: $scope.serviceConfigure.memoryLimit,
memoryLimit: parseInt($scope.serviceConfigure.memoryLimit),
recoveryMode: $scope.serviceConfigure.recoveryMode
};
@@ -119,7 +126,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
},
resetToDefaults: function () {
$scope.serviceConfigure.memoryLimit = 256 * 1024 * 1024; // 256MB default
$scope.serviceConfigure.memoryLimit = $scope.serviceConfigure.service.defaultMemoryLimit;
},
reset: function () {
+4 -4
View File
@@ -7,7 +7,7 @@
<h4 class="modal-title">{{ 'settings.updateDialog.title' | tr }} <b>{{config.update.box.version}}</b> </h4>
</div>
<div class="modal-body">
<div ng-hide="installedApps | readyToUpdate">
<div ng-hide="installedApps | canUpdate">
<p>{{ 'settings.updateDialog.blockingApps' | tr }}</p>
<ul>
<li ng-repeat="app in installedApps | inProgressApps">{{app.fqdn}}</li>
@@ -17,7 +17,7 @@
<br/>
</div>
<div ng-show="installedApps | readyToUpdate">
<div ng-show="installedApps | canUpdate">
<p class="text-danger" ng-show="config.update.box.unstable">{{ 'settings.updateDialog.unstableWarning' | tr }}</p>
<p>{{ 'settings.updateDialog.changes' | tr }}:</p>
<ul>
@@ -28,12 +28,12 @@
</div>
</div>
<div class="modal-footer">
<label class="checkbox-inline pull-left">
<label ng-show="installedApps | canUpdate" class="checkbox-inline pull-left">
<input type="checkbox" ng-model="update.skipBackup">{{ 'settings.updateDialog.skipBackupCheckbox' | tr }}
</label>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy" ng-show="(installedApps | readyToUpdate)"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
<button type="button" class="btn" ng-show="installedApps | canUpdate" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
+10 -62
View File
@@ -18,76 +18,24 @@
</div>
</div>
<!-- <div class="text-left">
<h3>{{ 'support.ticket.title' | tr }}</h3>
<div class="text-left" ng-if="troubleshoot">
<h3>Troubleshoot</h3>
</div>
<div class="card">
<div class="card" ng-if="troubleshoot">
<div class="grid-item-top">
<div class="row" ng-hide="ready">
<h2 class="text-center"><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="row" ng-show="ready">
<div class="row">
<div class="col-lg-12">
<div ng-show="subscription && !subscription.emailVerified" style="margin-bottom: 30px;">
<p class="text-bold">
{{ 'support.ticket.emailNotVerified' | tr:{ email: subscription.email } }}
<br/>
<center>
<a ng-href="{{ config.consoleServerOrigin }}" target="_blank" class="btn btn-success">{{ 'support.ticket.emailVerifyAction' | tr }}</a>
</center>
</p>
<p>Troubleshooting tools</p>
<div>
<button class="btn btn-default pull-right" ng-click="repairAll()"><i ng-show="repairAllBusy" class="fa fa-circle-notch fa-spin"></i> Repair All</button>
<button class="btn btn-default pull-right" ng-click="updateAll()"><i ng-show="updateAllBusy" class="fa fa-circle-notch fa-spin"></i> Update All</button>
</div>
<p>Use this form to open support tickets. You can also write directly to <a href="mailto:support@cloudron.io">support@cloudron.io.</p>
<ul>
<li><a href="https://docs.cloudron.io/apps/?support_view" target="_blank">Knowledge Base & App Docs</a></li>
<li><a href="https://docs.cloudron.io/custom-apps/tutorial/?support_view" target="_blank">Custom App Packaging & API</li>
<li><a href="https://forum.cloudron.io/" target="_blank">Forum</a></li>
</ul>
<form name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<label>{{ 'support.ticket.type' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required ng-disabled="!subscription.emailVerified">
<option value="app_error">{{ 'support.ticket.typeApp' | tr }}</option>
<option value="ticket">{{ 'support.ticket.typeBug' | tr }}</option>
<option value="billing">{{ 'support.ticket.typeBilling' | tr }}</option>
<option value="email_error">{{ 'support.ticket.typeEmail' | tr }}</option>
</select>
</div>
<div class="form-group" ng-show="feedback.type === 'app_error'">
<label>{{ 'support.ticket.selectApp' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'" ng-disabled="!subscription.emailVerified">
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
<label>{{ 'support.ticket.topic' | tr }}</label>
<input type="text" class="form-control" name="subject" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
<label>{{ 'support.ticket.report' | tr }}</label>
<textarea class="form-control" name="description" rows="3" placeholder="{{ 'support.ticket.reportPlaceholder' | tr }}" ng-model="feedback.description" ng-minlength="1" required ng-disabled="!subscription.emailVerified"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': feedbackForm.email.$invalid }">
<label>{{ 'support.ticket.email' | tr }}</label> <small>{{ 'support.ticket.emailInfo' | tr:{ email: subscription.email } }}</small>
<input type="email" class="form-control" name="email" placeholder="{{ 'support.ticket.emailPlaceholder' | tr }}" ng-model="feedback.altEmail" ng-required="feedback.type === 'email_error'" ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group">
<label class="control-label">
<input type="checkbox" ng-model="feedback.enableSshSupport" ng-disabled="!subscription.emailVerified"> {{ 'support.ticket.sshCheckbox' | tr }}
</label>
</div>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="!subscription.emailVerified || feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-notch fa-spin" ng-show="feedback.busy"></i> {{ 'support.ticket.submitAction' | tr }}</button>
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
<span ng-show="feedback.result" class="text-success text-bold">{{feedback.result.message}}</span>
</form>
<p class="text-small text-warning">{{ troubleshootingMessage }}</p>
</div>
</div>
</div>
</div> -->
</div>
<div class="text-left section-header">
<h3>{{ 'support.remoteSupport.title' | tr }}</h3>
+48 -63
View File
@@ -2,6 +2,8 @@
/* global angular:false */
/* global $:false */
/* global ISTATES */
/* global async */
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
@@ -9,69 +11,62 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
// $scope.apps = Client.getInstalledApps();
// $scope.appsById = {};
// $scope.feedback = {
// error: null,
// result: null,
// busy: false,
// enableSshSupport: false,
// subject: '',
// type: 'app_error',
// description: '',
// appId: '',
// altEmail: ''
// };
$scope.installedApps = Client.getInstalledApps();
$scope.toggleSshSupportError = '';
$scope.sshSupportEnabled = false;
// $scope.subscription = null;
// function resetFeedback() {
// $scope.feedback.enableSshSupport = false;
// $scope.feedback.subject = '';
// $scope.feedback.description = '';
// $scope.feedback.type = 'app_error';
// $scope.feedback.appId = '';
// $scope.feedback.altEmail = '';
$scope.troubleshoot = $location.search().troubleshoot;
// $scope.feedbackForm.$setUntouched();
// $scope.feedbackForm.$setPristine();
// }
$scope.updateAllBusy = false;
$scope.repairAllBusy = false;
$scope.troubleshootingMessage = '';
// $scope.submitFeedback = function () {
// $scope.feedback.busy = true;
// $scope.feedback.result = null;
// $scope.feedback.error = null;
$scope.updateAll = function () {
$scope.updateAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0, unstable = 0;
// var data = {
// enableSshSupport: $scope.feedback.enableSshSupport,
// subject: $scope.feedback.subject,
// description: $scope.feedback.description,
// type: $scope.feedback.type,
// appId: $scope.feedback.appId,
// altEmail: $scope.feedback.altEmail
// };
Client.checkForUpdates(function (error) {
if (error) Client.error(error);
// Client.createTicket(data, function (error, result) {
// if (error) {
// $scope.feedback.error = error.message;
// } else {
// $scope.feedback.result = result;
// resetFeedback();
// }
async.eachSeries(Object.keys($scope.config.update), function (appId, iteratorDone) {
if ($scope.config.update[appId].unstable) { ++unstable; return iteratorDone(); }
// $scope.feedback.busy = false;
Client.updateApp(appId, $scope.config.update[appId].manifest, { skipBackup: false }, function (error) {
if (error) Client.error(error);
else ++count;
// // refresh state
// Client.getRemoteSupport(function (error, enabled) {
// if (error) return console.error(error);
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps updated. ${unstable} apps with unstable updates skipped.`;
$scope.updateAllBusy = false;
});
});
};
// $scope.sshSupportEnabled = enabled;
// });
// });
// };
$scope.repairAll = function () {
$scope.repairAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0;
Client.refreshInstalledApps(function () {
async.eachSeries($scope.installedApps, function (app, iteratorDone) {
if (app.installationState !== ISTATES.ERROR) return iteratorDone();
Client.repairApp(app.id, {}, function (error) {
if (error) Client.error(error);
else ++count;
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps repaired.`;
$scope.repairAllBusy = false;
});
});
};
$scope.toggleSshSupport = function () {
$scope.toggleSshSupportError = '';
@@ -93,17 +88,7 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.sshSupportEnabled = enabled;
// Client.getSubscription(function (error, result) {
// if (error && error.statusCode === 402) return $scope.ready = true; // not yet registered
// if (error && error.statusCode === 412) return $scope.ready = true; // invalid appstore token
// if (error) return console.error(error);
// $scope.subscription = result;
// Client.getInstalledApps().forEach(function (app) { $scope.appsById[app.id] = app; });
$scope.ready = true;
// });
$scope.ready = true;
});
});
+1 -1
View File
@@ -100,7 +100,7 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
else content.label = content.app.label || content.app.fqdn;
} else if (content.type === 'volume') {
content.volume = $scope.volumesById[content.id];
content.label = content.volume.name;
content.label = content.volume ? content.volume.name : 'Removed volume';
}
// ensure a label for ui
+1 -1
View File
@@ -493,7 +493,7 @@
<div class="col-md-12">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.discoveryUrl' | tr }}</td>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.discoveryUrl' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#endpoints" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/.well-known/openid-configuration</td>
</tr>
</table>
+7 -4
View File
@@ -43,9 +43,6 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
},
submit: function () {
// prevent the current user from getting locked out
if ($scope.profileConfig.mandatory2FA && !$scope.userInfo.twoFactorAuthenticationEnabled) return Client.notify('', $translate.instant('users.settings.require2FAWarning'), true, 'error', '#/profile');
$scope.profileConfig.error = '';
$scope.profileConfig.busy = true;
$scope.profileConfig.success = false;
@@ -67,6 +64,12 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$timeout(function () {
$scope.profileConfig.busy = false;
// prevent the current user from getting locked out. if user ignores this, they have to use cloudron-support --admin-login
if ($scope.profileConfig.mandatory2FA && !$scope.userInfo.twoFactorAuthenticationEnabled) {
if ($scope.userInfo.source && $scope.config.external2FA) return; // no need for warning if 2fa is external
Client.notify('', $translate.instant('users.settings.require2FAWarning'), true, 'error', '#/profile');
}
}, 500);
});
}
@@ -128,7 +131,7 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
// fields
provider: 'noop',
autoCreate: false,
autoCreate: true,
url: '',
acceptSelfSignedCerts: false,
baseDn: '',
-55
View File
@@ -292,48 +292,6 @@
</div>
</div>
<!-- Modal user import -->
<div class="modal fade" id="userImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.userImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!userImport.done">
<div ng-show="!userImport.busy">
<p ng-bind-html=" 'users.userImportDialog.description' | tr:{ docsLink: 'https://docs.cloudron.io/user-management/#import-users' } "></p>
<input type="file" style="display: none;" id="userImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="userImport.openFileInput()">{{ 'users.userImportDialog.fileInput' | tr }}</button>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userImport.sendInvite" id="inputUserImportSendInvite"> {{ 'users.userImportDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<p class="text-danger" ng-show="userImport.error.file">{{ userImport.error.file }}</p>
<p class="text-info" ng-show="userImport.users.length">{{ 'users.userImportDialog.usersFound' | tr:{ count: userImport.users.length } }}</p>
</div>
<div ng-show="userImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ userImport.percent }}%"></div>
</div>
</div>
<div ng-show="userImport.done">
<p>{{ 'users.userImportDialog.success' | tr:{ count: userImport.success } }}</p>
<div ng-show="userImport.error.import.length">
<p class="text-danger">{{ 'users.userImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in userImport.error.import"><b>{{ tmp.user.email }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="userImport.import()" ng-show="!userImport.done" ng-disabled="userImport.busy || !userImport.users.length"><i class="fa fa-circle-notch fa-spin" ng-show="userImport.busy"></i> {{ 'users.userImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal password reset -->
<div class="modal fade" id="passwordResetModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -454,19 +412,6 @@
<input type="text" id="userSearchInput" class="form-control" style="max-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<multiselect ng-model="userStateFilter" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ userStateFilter }}" options="state.label for state in userStates" data-multiple="false"></multiselect>
<div style="flex-grow: 1;"></div>
<!-- import/export buttons are hidden until we figure what the exact use case is -->
<div class="btn-group" ng-hide="true">
<button class="btn btn-default" ng-click="userImport.show()" uib-tooltip="{{ 'users.userImport.tooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'users.userExport.tooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="userExport('csv')">{{ 'users.userExport.csv' | tr }}</a></li>
<li><a href="" ng-click="userExport('json')">{{ 'users.userExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
+1 -165
View File
@@ -67,171 +67,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return true;
};
$scope.userImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
users: [],
sendInvite: false,
reset: function () {
$scope.userImport.busy = false;
$scope.userImport.error = null;
$scope.userImport.users = [];
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.sendInvite = false;
},
handleFileChanged: function () {
$scope.userImport.reset();
var fileInput = document.getElementById('userImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.userImport.users = [];
var users = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.userImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 5) {
$scope.userImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 5' };
return;
}
users.push({
username: items[0].trim(),
email: items[1].trim(),
fallbackEmail: items[2].trim(),
displayName: items[3].trim(),
role: items[4].trim()
});
}
} else {
try {
users = JSON.parse(reader.result).map(function (user) {
return {
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role
};
});
} catch (e) {
console.error('Failed to parse users.', e);
$scope.userImport.error = { file: 'Imported file is not valid JSON:' + e.message };
}
}
$scope.userImport.users = users;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.userImport.reset();
// named so no duplactes
document.getElementById('userImportFileInput').addEventListener('change', $scope.userImport.handleFileChanged);
$('#userImportModal').modal('show');
},
openFileInput: function () {
$('#userImportFileInput').click();
},
import: function () {
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.error = { import: [] };
$scope.userImport.busy = true;
var processed = 0;
async.eachSeries($scope.userImport.users, function (user, callback) {
Client.addUser(user, function (error, userId) {
if (error) $scope.userImport.error.import.push({ error: error, user: user });
else ++$scope.userImport.success;
++processed;
$scope.userImport.percent = 100 * processed / $scope.userImport.users.length;
if (!error && $scope.userImport.sendInvite) {
console.log('sending', userId, user.email);
Client.sendInviteEmail(userId, user.email, function (error) {
if (error) console.error('Failed to send invite.', error);
});
}
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.userImport.busy = false;
$scope.userImport.done = true;
if ($scope.userImport.success) {
refreshCurrentPage();
refreshAllUsers();
}
});
}
};
// supported types are 'json' and 'csv'
$scope.userExport = function (type) {
Client.getAllUsers(function (error, result) {
if (error) {
Client.error('Failed to list users. Full error in the webinspector.');
return console.error('Failed to list users.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (user) {
return {
id: user.id,
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role,
active: user.active
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (user) {
return [ user.id, user.username, user.email, user.fallbackEmail, user.displayName, user.role, user.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = type === 'json' ? 'users.json' : 'users.csv';
document.body.appendChild(a);
a.click();
});
};
$scope.userRemove = {
busy: false,
error: null,
@@ -480,6 +315,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.groupAdd.error = {};
$scope.groupAdd.name = '';
$scope.groupAdd.selectedUsers = [];
$scope.groupAddForm.$setUntouched();
$scope.groupAddForm.$setPristine();
+4 -4
View File
@@ -95,7 +95,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
ext4Disk: null, // { path, type }
xfsDisk: null, // { path, type }
user: '',
seal: false,
seal: true,
port: 22,
privateKey: '',
@@ -112,7 +112,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.volumeAdd.ext4Disk = null;
$scope.volumeAdd.xfsDisk = null;
$scope.volumeAdd.user = '';
$scope.volumeAdd.seal = false;
$scope.volumeAdd.seal = true;
$scope.volumeAdd.port = 22;
$scope.volumeAdd.privateKey = '';
@@ -213,7 +213,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
username: '',
password: '',
user: '',
seal: false,
seal: true,
port: 22,
privateKey: '',
@@ -224,7 +224,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.volumeEdit.name = '';
$scope.volumeEdit.mountType = '';
$scope.volumeEdit.host = '';
$scope.volumeEdit.seal = '';
$scope.volumeEdit.seal = true;
$scope.volumeEdit.port = '';
$scope.volumeEdit.remoteDir = '';
$scope.volumeEdit.username = '';
+21
View File
@@ -0,0 +1,21 @@
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
js.configs.recommended,
{
files: ["**/*.js"],
languageOptions: {
globals: {
...globals.node,
},
ecmaVersion: 13,
sourceType: "commonjs"
},
rules: {
semi: "error",
"prefer-const": "error"
}
}
];
+22
View File
@@ -0,0 +1,22 @@
import globals from 'globals';
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
export default [
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
files: ["**/*.js"],
languageOptions: {
globals: {
...globals.browser,
},
ecmaVersion: 13,
sourceType: 'module'
},
rules: {
semi: "error",
"prefer-const": "error"
}
}
];
+7
View File
@@ -5,6 +5,13 @@
<link rel="icon" href="/api/v1/cloudron/avatar" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File Manager</title>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="app"></div>
+7
View File
@@ -5,6 +5,13 @@
<link rel="icon" href="/api/v1/cloudron/avatar" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Logs</title>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="app"></div>
+1587 -883
View File
File diff suppressed because it is too large Load Diff
+19 -17
View File
@@ -1,33 +1,35 @@
{
"name": "my-vue-app",
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --base=/frontend/ --strictPort --port 4001",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.0.19",
"@fontsource/noto-sans": "^5.0.22",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.0",
"marked": "^12.0.1",
"filesize": "^10.1.4",
"marked": "^13.0.3",
"moment": "^2.30.1",
"pankow": "^1.2.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"superagent": "^8.1.2",
"vue": "^3.4.21",
"vue-i18n": "^9.10.1",
"vue-router": "^4.3.0",
"@xterm/xterm": "^5.4.0",
"@xterm/addon-attach": "^0.10.0",
"@xterm/addon-fit": "^0.9.0"
"pankow": "^1.7.3",
"pankow-viewers": "^1.0.4",
"superagent": "^9.0.2",
"vue": "^3.4.38",
"vue-i18n": "^9.14.0",
"vue-router": "^4.4.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.5"
"@eslint/js": "^9.9.0",
"@vitejs/plugin-vue": "^5.1.2",
"eslint": "^9.9.0",
"eslint-plugin-vue": "^9.27.0",
"vite": "^5.4.1"
}
}
+2 -4
View File
@@ -1,14 +1,12 @@
<template>
<ConfirmDialog/>
<!-- router-view needs some fake node first for some unknown reason -->
<span></span>
<router-view></router-view>
</template>
<script>
import ConfirmDialog from 'primevue/confirmdialog';
export default {
components: { ConfirmDialog },
data() {
return {
};
+43 -29
View File
@@ -8,18 +8,17 @@
<span class="title">{{ name }}</span>
</template>
<template #right>
<Button type="button" :label="$t('logs.clear')" icon="pi pi-eraser" @click="onClear()" style="margin-right: 5px" />
<a :href="downloadUrl" target="_blank"><Button :label="$t('logs.download')" icon="pi pi-download" /></a>
<a class="hide-phone" style="margin-left: 5px; margin-right: 5px;" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="type === 'app'"><Button type="button" severity="secondary" icon="pi pi-folder" :label="$t('filemanager.title')" /></a>
<a class="hide-phone" style="margin-right: 5px;" :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="type === 'app'"><Button severity="secondary" icon="pi pi-desktop" :label="$t('terminal.title')" /></a>
<Button class="hide-phone" type="button" :label="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="type === 'app'"/>
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
</template>
</TopBar>
</template>
<template #body>
<div v-for="line of logLines" class="log-line">
<span class="time">{{ line.time || '[no timestamp]&nbsp;' }}</span> <span v-html="line.html"></span>
</div>
<div ref="linesContainer"></div>
<div class="bottom-spacer"></div>
</template>
</MainLayout>
@@ -27,13 +26,7 @@
<script>
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { TopBar, MainLayout } from 'pankow';
import { Button, TopBar, MainLayout } from 'pankow';
import LogsModel from '../models/LogsModel.js';
import AppModel from '../models/AppModel.js';
@@ -44,11 +37,7 @@ export default {
name: 'LogsViewer',
components: {
Button,
Dialog,
InputText,
MainLayout,
Menu,
ProgressSpinner,
TopBar
},
data() {
@@ -58,6 +47,9 @@ export default {
logsModel: null,
appModel: null,
busyRestart: false,
showRestart: false,
showFilemanager: false,
showTerminal: false,
id: '',
name: '',
type: '',
@@ -67,7 +59,7 @@ export default {
},
methods: {
onClear() {
this.logLines = [];
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
},
onDownload() {
this.logsModel.download();
@@ -125,9 +117,13 @@ export default {
if (this.type === 'app') {
this.appModel = AppModel.create(this.apiOrigin, this.accessToken, this.id);
try {
const app = await this.appModel.get();
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
this.showFilemanager = !!app.manifest.addons.localstorage;
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
} catch (e) {
console.error(`Failed to get app info for ${this.id}:`, e);
}
@@ -137,14 +133,32 @@ export default {
this.downloadUrl = this.logsModel.getDownloadUrl();
const maxLines = 1000;
let lines = 0;
let newLogLines = [];
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
setInterval(() => {
newLogLines = newLogLines.slice(-maxLines)
for (let line of newLogLines) {
if (lines < maxLines) ++lines;
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp]&nbsp;' }</span> <span>${line.html}</span>`;
this.$refs.linesContainer.appendChild(logLine);
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
}
newLogLines = [];
}, 500);
this.logsModel.stream((time, html) => {
this.logLines.push({ time, html});
const tmp = document.getElementsByClassName('cloudron-layout-body')[0];
if (!tmp) return;
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
newLogLines.push({ time, html });
}, function (error) {
console.error('Failed to start log stream:', error);
})
@@ -163,7 +177,7 @@ body {
font-size: 20px;
}
.cloudron-layout-body {
.pankow-main-layout-body {
cursor: text;
}
@@ -173,7 +187,7 @@ body {
}
}
.cloudron-top {
.pankow-top-bar {
background-color: black;
color: white;
margin-bottom: 0 !important;
+57 -75
View File
@@ -1,24 +1,12 @@
<template>
<MainLayout :gap="false">
<template #dialogs>
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
<Dialog ref="fatalErrorDialog" modal title="Error">
<p>{{ fatalError }}</p>
</Dialog>
<Dialog v-model:visible="downloadFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('downloadFileDialogNameInput')">
<template #header>
<label class="dialog-header" for="downloadFileDialogNameInput">{{ $t('terminal.downloadAction') }}</label>
</template>
<template #default>
<form @submit="onDownloadFileDialogSubmit" @submit.prevent>
<p :v-show="downloadFileDialog.error">{{ downloadFileDialog.error }}</p>
<label for="downloadFileDialogNameInput">{{ $t('terminal.download.filePath') }}</label>
<InputText class="dialog-single-input" :class="{ 'p-invalid': downloadFileDialog.error }" id="downloadFileDialogNameInput" v-model="downloadFileDialog.name" :disabled="downloadFileDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('terminal.download.download')" :loading="downloadFileDialog.busy" :disabled="downloadFileDialog.busy || !downloadFileDialog.name"/>
</form>
<a id="fileDownloadLink" :href="downloadFileDialog.downloadUrl" target="_blank"></a>
</template>
</Dialog>
<InputDialog ref="inputDialog" />
<a id="fileDownloadLink" :href="downloadFileDownloadUrl" target="_blank"></a>
</template>
<template #header>
<TopBar class="navbar">
@@ -27,22 +15,21 @@
</template>
<template #right>
<!-- Scheduler/cron tasks -->
<Button severity="success" :label="$t('terminal.scheduler')" v-show="usesAddon('scheduler')" icon="pi pi-angle-down" iconPos="right" @click="onSchedulerMenu" aria-haspopup="true" aria-controls="schedulerMenu" style="margin-right: 5px" />
<Menu ref="schedulerMenu" id="schedulerMenu" :model="schedulerMenuModel" :popup="true" />
<Button success :menu="schedulerMenuModel" v-show="usesAddon('scheduler')" @click="onSchedulerMenu">{{ $t('terminal.scheduler') }}</Button>
<!-- addon actions -->
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('mysql')" v-show="usesAddon('mysql')" :disabled="!connected" label="MySQL"/>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('postgresql')" v-show="usesAddon('postgresql')" :disabled="!connected" label="Postgres"/>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('mongodb')" v-show="usesAddon('mongodb')" :disabled="!connected" label="MongoDB"/>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('redis')" v-show="usesAddon('redis')" :disabled="!connected" label="Redis"/>
<Button success @click="terminalInject('mysql')" v-show="usesAddon('mysql')" :disabled="!connected">MySQL</Button>
<Button success @click="terminalInject('postgresql')" v-show="usesAddon('postgresql')" :disabled="!connected">Postgres</Button>
<Button success @click="terminalInject('mongodb')" v-show="usesAddon('mongodb')" :disabled="!connected">MongoDB</Button>
<Button success @click="terminalInject('redis')" v-show="usesAddon('redis')" :disabled="!connected">Redis</Button>
<!-- upload/download actions -->
<Button severity="primary" style="margin-right: 5px;" :disabled="!connected" @click="onUpload" icon="pi pi-upload" :label="$t('terminal.uploadToTmp')"/>
<Button severity="primary" style="margin-right: 5px;" :disabled="!connected" @click="onDownload" icon="pi pi-download" :label="$t('terminal.downloadAction')"/>
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadTo', { path: '/app/data/' }) }}</Button>
<Button :disabled="!connected" @click="onDownload" icon="fa-solid fa-download">{{ $t('terminal.downloadAction') }}</Button>
<Button style="margin-right: 5px;" severity="secondary" type="button" v-tooltip.bottom="$t('filemanager.toolbar.restartApp')" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart"/>
<a style="margin-right: 5px;" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank"><Button severity="secondary" type="button" icon="pi pi-folder" v-tooltip.bottom="$t('filemanager.title')" /></a>
<a :href="'/frontend/logs.html?appId=' + id" target="_blank"><Button severity="secondary" icon="pi pi-align-left" v-tooltip.bottom="$t('logs.title')"/></a>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</template>
</TopBar>
</template>
@@ -63,13 +50,7 @@
import superagent from 'superagent';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { TopBar, MainLayout, FileUploader } from 'pankow';
import { Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm';
@@ -77,6 +58,7 @@ import { AttachAddon } from '@xterm/addon-attach';
import { FitAddon } from '@xterm/addon-fit';
import { create } from '../models/AppModel.js';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
@@ -86,10 +68,8 @@ export default {
Button,
Dialog,
FileUploader,
InputText,
InputDialog,
MainLayout,
Menu,
ProgressSpinner,
TopBar
},
data() {
@@ -97,10 +77,12 @@ export default {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
appModel: null,
directoryModel: null,
fatalError: false,
busyRestart: false,
connected: false,
addons: {},
showFilemanager: false,
schedulerTasks: [],
manifestVersion: '',
schedulerMenuModel: [],
@@ -108,61 +90,57 @@ export default {
name: '',
socket: null,
terminal: null,
downloadFileDialog: {
busy: false,
name: '',
error: '',
downloadUrl: '',
visible: false,
}
downloadFileDownloadUrl: ''
};
},
methods: {
onFatalError(errorMessage) {
this.fatalError = errorMessage;
this.$refs.fatalErrorDialog.open();
},
// generic dialog focus handler TODO move to pankow and reuse in filemanger
onDialogShow(focusElementId) {
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
},
onDownload() {
this.downloadFileDialog.busy = false;
this.downloadFileDialog.name = '';
this.downloadFileDialog.error = '';
this.downloadFileDialog.downloadUrl = '';
this.downloadFileDialog.visible = true;
},
async onDownloadFileDialogSubmit() {
this.downloadFileDialog.busy = true;
async onDownload() {
this.downloadFileDownloadUrl = '';
const downloadFileName = await this.$refs.inputDialog.prompt({
message: this.$t('terminal.downloadAction'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('terminal.download.download'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!downloadFileName) return;
try {
const result = await superagent.head(`${this.apiOrigin}/api/v1/apps/${this.id}/download`).query({
file: this.downloadFileDialog.name,
file: downloadFileName,
access_token: this.accessToken
});
} catch (error) {
this.downloadFileDialog.busy = false;
if (error.status === 404) this.downloadFileDialog.error = 'The requested file does not exist.';
if (error.status === 404) console.error('The requested file does not exist.');
else console.error('Failed', error);
return;
}
this.downloadFileDialog.downloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(this.downloadFileDialog.name)}&access_token=${this.accessToken}`;
this.downloadFileDownloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
// we have to click the link to make the browser do the download
// don't know how to prevent the browsers
this.$nextTick(() => {
document.getElementById('fileDownloadLink').click();
this.downloadFileDialog.visible = false;
});
},
onUpload() {
this.$refs.fileUploader.onUploadFile('/tmp');
this.$refs.fileUploader.onUploadFile('/');
},
async uploadHandler(targetDir, file, progressHandler) {
await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/upload`)
.query({ access_token: this.accessToken, file: `${targetDir}/${file.name}` })
.attach('file', file)
.on('progress', progressHandler);
await this.directoryModel.upload(targetDir, file, progressHandler);
},
usesAddon(addon) {
return !!Object.keys(this.addons).find(function (a) { return a === addon; });
@@ -192,6 +170,8 @@ export default {
} else if (addon === 'redis') {
if (this.manifestVersion === 1) {
cmd = 'redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDIS_PASSWORD}"';
} else if (this.addons['redis'].noPassword) {
cmd = 'redis-cli -h "${CLOUDRON_REDIS_HOST}" -p "${CLOUDRON_REDIS_PORT}"';
} else {
cmd = 'redis-cli -h "${CLOUDRON_REDIS_HOST}" -p "${CLOUDRON_REDIS_PORT}" -a "${CLOUDRON_REDIS_PASSWORD}" --no-auth-warning';
}
@@ -215,7 +195,7 @@ export default {
this.busyRestart = false;
},
async connect(retry = false) {
document.getElementsByClassName('cloudron-layout-body')[0].innerHTML = '';
document.getElementsByClassName('pankow-main-layout-body')[0].innerHTML = '';
let execId;
try {
@@ -227,7 +207,7 @@ export default {
}
this.terminal = new Terminal();
this.terminal.open(document.getElementsByClassName('cloudron-layout-body')[0]);
this.terminal.open(document.getElementsByClassName('pankow-main-layout-body')[0]);
if (retry) this.terminal.writeln('Reconnecting...');
else this.terminal.writeln('Connecting...');
@@ -247,11 +227,6 @@ export default {
this.terminal.loadAddon(new AttachAddon(this.socket));
// Let the browser handle paste
// this.terminal.attachCustomKeyEventHandler((event) => {
// if (event.key === 'V' && (event.ctrlKey || event.metaKey)) return false;
// });
this.socket.addEventListener('open', (event) => {
this.connected = true;
});
@@ -289,27 +264,34 @@ export default {
this.name = id;
this.appModel = create(this.apiOrigin, this.accessToken, this.id);
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, `apps/${id}`);
try {
const app = await this.appModel.get();
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
this.addons = app.manifest.addons;
this.manifestVersion = app.manifest.manifestVersion;
this.showFilemanager = !!app.manifest.addons.localstorage;
this.schedulerMenuModel = !app.manifest.addons.scheduler ? [] : Object.keys(app.manifest.addons.scheduler).map((k) => {
return {
label: () => k,
command: () => this.terminalInject('scheduler', app.manifest.addons.scheduler[k].command)
label: k,
action: () => this.terminalInject('scheduler', app.manifest.addons.scheduler[k].command)
};
});
} catch (e) {
console.error(`Failed to get app info for ${this.id}:`, e);
this.fatalError = `Unknown app ${this.id}. Cannot continue.`;
return;
return this.onFatalError(`Unknown app ${this.id}. Cannot continue.`);
}
window.document.title = `Terminal - ${this.name}`;
window.addEventListener('beforeunload', function (e) {
e.stopPropagation();
e.preventDefault();
return false;
}, true );
window.addEventListener('keydown', (event) => {
if (event.key === 'C' && (event.ctrlKey || event.metaKey)) { // ctrl shift c
event.preventDefault();
@@ -342,7 +324,7 @@ body {
font-size: 20px;
}
.cloudron-top {
.pankow-top-bar {
background-color: black;
color: white;
margin-bottom: 0 !important;
+12 -14
View File
@@ -5,15 +5,7 @@ import './style.css';
import '@fontsource/noto-sans';
import 'primevue/resources/themes/saga-blue/theme.css';
// import 'primevue/resources/themes/arya-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import Tooltip from 'primevue/tooltip';
import { createRouter, createWebHashHistory } from 'vue-router';
@@ -35,9 +27,18 @@ const router = createRouter({
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
@@ -72,9 +73,6 @@ const i18n = createI18n({
app.use(i18n);
app.use(router);
app.use(PrimeVue, { ripple: true });
app.directive('tooltip', Tooltip);
app.use(ConfirmationService);
app.mount('#app');
})();
+12 -12
View File
@@ -5,22 +5,24 @@ import './style.css';
import '@fontsource/noto-sans';
import 'primevue/resources/themes/saga-blue/theme.css';
// import 'primevue/resources/themes/arya-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import LogsViewer from './components/LogsViewer.vue';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
@@ -54,8 +56,6 @@ const i18n = createI18n({
const app = createApp(LogsViewer);
app.use(i18n);
app.use(PrimeVue, { ripple: true });
app.use(ConfirmationService);
app.mount('#app');
})();
+67 -16
View File
@@ -67,24 +67,49 @@ export function createDirectoryModel(origin, accessToken, api) {
return result.body.entries;
},
async upload(targetDir, file, progressHandler) {
upload(targetDir, file, progressHandler) {
// file may contain a file name or a file path + file name
const relativefilePath = (file.webkitRelativePath ? file.webkitRelativePath : file.name);
await superagent.post(`${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}`)
.query({ access_token: accessToken })
.attach('file', file)
.on('progress', progressHandler);
const xhr = new XMLHttpRequest();
const req = new Promise(function (resolve, reject) {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject({
status: xhr.status,
statusText: xhr.statusText
});
}
});
xhr.addEventListener('error', () => {
reject({
status: xhr.status,
statusText: xhr.statusText
});
});
xhr.upload.addEventListener('progress', (event) => {
if (event.loaded) progressHandler({ direction: 'upload', loaded: event.loaded});
});
xhr.open('POST', `${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}?access_token=${accessToken}&overwrite=true`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.send(file);
});
// attach for upstream xhr.abort()
req.xhr = xhr;
return req;
},
async newFile(folderPath, fileName) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.attach('file', new File([], fileName));
async newFile(filePath) {
await this.save(filePath, '');
},
async newFolder(folderPath) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.send({ directory: true });
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}?access_token=${accessToken}&directory=true`);
},
async remove(filePath) {
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
@@ -115,10 +140,36 @@ export function createDirectoryModel(origin, accessToken, api) {
},
async save(filePath, content) {
const file = new File([content], 'file');
await superagent.post(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken })
.attach('file', file)
.field('overwrite', 'true');
const req = new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject({
status: xhr.status,
statusText: xhr.statusText
});
}
});
xhr.addEventListener('error', () => {
reject({
status: xhr.status,
statusText: xhr.statusText
});
});
xhr.open('POST', `${origin}/api/v1/${api}/files/${filePath}?access_token=${accessToken}&overwrite=true`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.setRequestHeader('Content-Length', file.size);
xhr.send(file);
});
const res = await req;
},
async getFile(path) {
let result;
+5 -18
View File
@@ -6,6 +6,7 @@ html, body {
padding: 0;
margin: 0;
background-color: #e5e5e5;
color: var(--pankow-text-color);
}
h1 {
@@ -29,22 +30,8 @@ a:hover, a:focus {
height: 100%;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(1.1);
}
.p-button {
font-family: Noto Sans !important;
}
.p-button.p-button-success, .p-buttonset.p-button-success > .p-button, .p-splitbutton.p-button-success > .p-button {
background: #27ce65 !important;
border: 1px solid #27ce65 !important;
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
+12 -15
View File
@@ -5,24 +5,24 @@ import './style.css';
import '@fontsource/noto-sans';
import 'primevue/resources/themes/saga-blue/theme.css';
// import 'primevue/resources/themes/arya-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import Tooltip from 'primevue/tooltip';
import Terminal from './components/Terminal.vue';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
@@ -56,9 +56,6 @@ const i18n = createI18n({
const app = createApp(Terminal);
app.use(i18n);
app.use(PrimeVue, { ripple: true });
app.use(ConfirmationService);
app.directive('tooltip', Tooltip);
app.mount('#app');
})();
+263 -277
View File
@@ -1,67 +1,45 @@
<template>
<MainLayout>
<template #dialogs>
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
<Dialog ref="fatalErrorDialog" modal title="Error">
<p>{{ fatalError }}</p>
</Dialog>
<Dialog v-model:visible="extractInProgress" modal :header="$t('filemanager.extractionInProgress')" :closable="false" :closeOnEscape="false">
<Dialog ref="extractInProgressDialog" modal :title="$t('filemanager.extractionInProgress')">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog v-model:visible="pasteInProgress" modal :header="$t('filemanager.pasteInProgress')" :closable="false" :closeOnEscape="false">
<Dialog ref="pasteInProgressDialog" modal :title="$t('filemanager.pasteInProgress')">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog v-model:visible="deleteInProgress" modal :header="$t('filemanager.deleteInProgress')" :closable="false" :closeOnEscape="false">
<Dialog ref="deleteInProgressDialog" modal :title="$t('filemanager.deleteInProgress')">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
</div>
</Dialog>
<!-- have to use v-model instead of : bind - https://github.com/primefaces/primevue/issues/815 -->
<Dialog v-model:visible="newFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFileDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFileDialogNameInput">{{ $t('filemanager.newFileDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFileDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFileDialogNameInput" v-model="newFileDialog.name" :disabled="newFileDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFileDialog.busy" :disabled="newFileDialog.busy || !newFileDialog.name"/>
</form>
</template>
</Dialog>
<Dialog v-model:visible="newFolderDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFolderDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFolderDialogNameInput">{{ $t('filemanager.newDirectoryDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFolderDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFolderDialogNameInput" v-model="newFolderDialog.name" :disabled="newFolderDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFolderDialog.busy" :disabled="newFolderDialog.busy || !newFolderDialog.name"/>
</form>
</template>
</Dialog>
<InputDialog ref="inputDialog" />
</template>
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="pi pi-refresh" @click="onRefresh()" text :loading="busyRefresh" style="margin-right: 5px;"/>
<PathBreadcrumbs :path="cwd" :activate-handler="onActivateBreadcrumb"/>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
<Button type="button" :label="$t('filemanager.toolbar.new')" icon="pi pi-plus" @click="onCreateMenu" aria-haspopup="true" aria-controls="create_menu" style="margin-right: 5px" />
<Menu ref="createMenu" id="create_menu" :model="createMenuModel" :popup="true" />
<Button type="button" :label="$t('filemanager.toolbar.upload')" icon="pi pi-upload" @click="onUploadMenu" aria-haspopup="true" aria-controls="upload_menu" style="margin-right: 5px" />
<Menu ref="uploadMenu" id="upload_menu" :model="uploadMenuModel" :popup="true" />
<Button icon="fa-solid fa-plus" @click="onCreateMenu">{{ $t('filemanager.toolbar.new') }}</Button>
<Menu ref="createMenu" :model="createMenuModel"/>
<Button icon="fa-solid fa-upload" @click="onUploadMenu">{{ $t('filemanager.toolbar.upload') }}</Button>
<Menu ref="uploadMenu" :model="uploadMenuModel"/>
<Button style="margin-left: 20px; margin-right: 5px;" type="button" v-tooltip.bottom="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="resourceType === 'app'"/>
<a style="margin-right: 5px;" :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'"><Button severity="secondary" icon="pi pi-desktop" v-tooltip.bottom="$t('terminal.title')" /></a>
<a :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'"><Button severity="secondary" icon="pi pi-align-left" v-tooltip.bottom="$t('logs.title')" /></a>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</template>
</TopBar>
</template>
@@ -69,6 +47,8 @@
<div class="main-view">
<div class="main-view-col">
<DirectoryView
class="directory-view"
:busy="busy"
:show-owner="true"
:show-size="true"
:show-modified="true"
@@ -95,7 +75,7 @@
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<div class="title-bar">
<div class="side-bar-title">
<a v-show="appLink" :href="appLink" target="_blank">{{ title }}</a>
<span v-show="!appLink">{{ title }}</span>
</div>
@@ -107,6 +87,7 @@
<FileUploader
ref="fileUploader"
:upload-handler="uploadHandler"
:cancel-handler="onCancelUpload"
@finished="onUploadFinished"
:tr="$t"
/>
@@ -122,15 +103,8 @@
import superagent from 'superagent';
import { marked } from 'marked';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { useConfirm } from 'primevue/useconfirm';
import { DirectoryView, TopBar, PathBreadcrumbs, BottomBar, MainLayout, FileUploader } from 'pankow';
import { Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
import Icon from 'pankow/components/Icon.vue';
import { sanitize, sleep } from 'pankow/utils';
import { ISTATES } from '../constants.js';
@@ -154,24 +128,23 @@ export default {
Dialog,
DirectoryView,
FileUploader,
InputText,
InputDialog,
MainLayout,
Menu,
PathBreadcrumbs,
Breadcrumb,
PreviewPanel,
ProgressSpinner,
TopBar
Spinner,
TopBar,
Icon
},
data() {
return {
busy: true,
fallbackIcon: `${BASE_URL}mime-types/none.svg`,
cwd: '/',
busyRefresh: false,
busyRestart: false,
fatalError: false,
extractInProgress: false,
pasteInProgress: false,
deleteInProgress: false,
footerContent: '',
activeItem: null,
activeDirectoryItem: {},
@@ -188,76 +161,176 @@ export default {
resourceType: '',
resourceId: '',
visible: true,
newFileDialog: {
visible: false,
busy: false,
name: ''
},
newFolderDialog: {
visible: false,
busy: false,
name: ''
uploadRequest: null,
breadcrumbHomeItem: {
label: '/app/data/',
action: () => {
this.cwd = '/';
}
},
ownersModel: [],
// contextMenuModel will have activeItem attached if any command() is called
createMenuModel: [{
label: () => this.$t('filemanager.toolbar.newFile'),
icon: 'pi pi-file',
command: this.onNewFile
label: this.$t('filemanager.toolbar.newFile'),
icon: 'fa-solid fa-file-circle-plus',
action: this.onNewFile
}, {
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onNewFolder
label: this.$t('filemanager.toolbar.newFolder'),
icon: 'fa-solid fa-folder-plus',
action: this.onNewFolder
}],
uploadMenuModel: [{
label: () => this.$t('filemanager.toolbar.uploadFile'),
icon: 'pi pi-file',
command: this.onUploadFile
label: this.$t('filemanager.toolbar.uploadFile'),
icon: 'fa-solid fa-file-arrow-up',
action: this.onUploadFile
}, {
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onUploadFolder
label: this.$t('filemanager.toolbar.newFolder'),
icon: 'fa-regular fa-folder-open',
action: this.onUploadFolder
}]
};
},
computed: {
breadcrumbItems() {
const parts = this.cwd.split('/').filter((p) => !!p.trim())
const crumbs = [];
parts.forEach((p, i) => {
crumbs.push({
label: p,
action: () => {
this.cwd = '/' + parts.slice(0, i+1).join('/');
}
});
});
return crumbs;
}
},
watch: {
cwd(newCwd, oldCwd) {
if (this.resourceType && this.resourceId) this.$router.push(`/home/${this.resourceType}/${this.resourceId}${this.cwd}`);
this.loadCwd();
}
},
async mounted() {
this.busy = true;
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'app') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} else if (type === 'volume') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.title = result.body.name;
} else {
return this.onFatalError(`Unsupported type ${type}`);
}
try {
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
});
},
methods: {
onCreateMenu(event) {
this.$refs.createMenu.toggle(event);
onFatalError(errorMessage) {
this.fatalError = errorMessage;
this.$refs.fatalErrorDialog.open();
},
onUploadMenu(event) {
this.$refs.uploadMenu.toggle(event);
onCreateMenu(event, elem = null) {
this.$refs.createMenu.open(event, elem);
},
onUploadMenu(event, elem = null) {
this.$refs.uploadMenu.open(event, elem);
},
onCancelUpload() {
if (!this.uploadRequest || !this.uploadRequest.xhr) return;
this.uploadRequest.xhr.abort();
},
// generic dialog focus handler
onDialogShow(focusElementId) {
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
},
onNewFile() {
this.newFileDialog.busy = false;
this.newFileDialog.name = '';
this.newFileDialog.visible = true;
},
async onNewFileDialogSubmit() {
this.newFileDialog.busy = true;
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, this.newFileDialog.name), this.newFileDialog.name);
async onNewFile() {
const newFileName = await this.$refs.inputDialog.prompt({
message: this.$t('filemanager.newFileDialog.title'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('filemanager.newFileDialog.create'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!newFileName) return;
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName));
await this.loadCwd();
this.newFileDialog.visible = false;
},
onNewFolder() {
this.newFolderDialog.busy = false;
this.newFolderDialog.name = '';
this.newFolderDialog.visible = true;
},
async onNewFolderDialogSubmit() {
this.newFolderDialog.busy = true;
await this.directoryModel.newFolder(this.directoryModel.buildFilePath(this.cwd, this.newFolderDialog.name));
async onNewFolder() {
const newFolderName = await this.$refs.inputDialog.prompt({
message: this.$t('filemanager.newDirectoryDialog.title'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('filemanager.newFileDialog.create'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!newFolderName) return;
await this.directoryModel.newFolder(this.directoryModel.buildFilePath(this.cwd, newFolderName));
await this.loadCwd();
this.newFolderDialog.visible = false;
},
onUploadFile() {
this.$refs.fileUploader.onUploadFile(this.cwd);
@@ -289,48 +362,62 @@ export default {
async onDrop(targetFolder, dataTransfer, files) {
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
// if dataTransfer is set, we have a file/folder drop from outside
if (dataTransfer) {
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
let folderItem;
try {
folderItem = dataTransfer.items[0].webkitGetAsEntry();
if (folderItem.isFile) return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
} catch (e) {
return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
async function getFile(entry) {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
// if we got here we have a folder drop and a modern browser
// now traverse the folder tree and create a file list
var that = this;
function traverseFileTree(item, path) {
async function readEntries(dirReader) {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
}
const fileList = [];
async function traverseFileTree(item) {
if (item.isFile) {
item.file(function (file) {
that.$refs.fileUploader.addFiles([file], sanitize(`${that.cwd}/${targetFolder}`), false);
});
fileList.push(await getFile(item));
} else if (item.isDirectory) {
// Get folder contents
var dirReader = item.createReader();
dirReader.readEntries(function (entries) {
for (let i in entries) {
traverseFileTree(entries[i], item.name);
}
});
const dirReader = item.createReader();
const entries = await readEntries(dirReader);
for (let i in entries) {
await traverseFileTree(entries[i], item.name);
}
} else {
console.log('Skipping uknown file type', item);
}
}
traverseFileTree(folderItem, '');
// collect all files to upload
for (const item of dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (entry.isFile) {
fileList.push(await getFile(entry));
} else if (entry.isDirectory) {
await traverseFileTree(entry, sanitize(`${this.cwd}/${targetFolder}`));
}
}
this.$refs.fileUploader.addFiles(fileList, sanitize(`${this.cwd}/${targetFolder}`));
} else {
if (!files.length) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.pasteInProgress = true;
this.$refs.pasteInProgressDialog.open();
// check ctrl for cut/copy
await this.directoryModel.paste(fullTargetFolder, 'cut', files);
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.pasteInProgress = false;
this.$refs.pasteInProgressDialog.close();
}
},
onItemActivated(item) {
@@ -343,39 +430,31 @@ export default {
async deleteHandler(files) {
if (!files) return;
function start_and_end(str) {
if (str.length > 100) {
return str.substr(0, 45) + ' ... ' + str.substr(str.length-45, str.length);
}
return str;
}
this.$confirm.require({
header: this.$t('filemanager.removeDialog.reallyDelete'),
message: start_and_end(files.map((f) => f.name).join(', ')),
icon: '',
acceptClass: 'p-button-danger',
accept: async () => {
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = true;
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
}
}
await this.loadCwd();
this.$confirm.close();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = false;
}
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.removeDialog.reallyDelete'),
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no'),
modal: false
});
if (!confirmed) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.deleteInProgressDialog.open();
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
}
}
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.deleteInProgressDialog.close();
},
async renameHandler(file, newName) {
if (file.name === newName) return;
@@ -385,16 +464,17 @@ export default {
await this.loadCwd();
} catch (e) {
if (e.status === 409) {
this.$confirm.require({
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.renameDialog.reallyOverwrite'),
icon: '',
acceptClass: 'p-button-danger',
accept: async () => {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
await this.loadCwd();
this.$confirm.close();
}
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no')
});
if (!confirmed) return;
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
await this.loadCwd();
}
else console.error(`Failed to rename ${file} to ${newName}`, e);
}
@@ -430,26 +510,35 @@ export default {
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.pasteInProgress = true;
this.$refs.pasteInProgressDialog.open();
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
this.clipboard = {};
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.pasteInProgress = false;
this.$refs.pasteInProgressDialog.close();
},
async downloadHandler(file) {
await this.directoryModel.download(this.directoryModel.buildFilePath(this.cwd, file.name));
},
async extractHandler(file) {
this.extractInProgress = true;
this.$refs.extractInProgressDialog.open();
await this.directoryModel.extract(this.directoryModel.buildFilePath(this.cwd, file.name));
await this.loadCwd();
this.extractInProgress = false;
this.$refs.extractInProgressDialog.close();
},
async uploadHandler(targetDir, file, progressHandler) {
await this.directoryModel.upload(targetDir, file, progressHandler);
this.uploadRequest = this.directoryModel.upload(targetDir, file, progressHandler);
try {
await this.uploadRequest;
} catch (e) {
console.log('Upload cancelled.', e);
}
this.uploadRequest = null;
await this.loadCwd();
},
async loadCwd() {
@@ -466,6 +555,8 @@ export default {
mimeType: 'inode/directory',
icon: `${BASE_URL}mime-types/inode-directory.svg`
};
this.busy = false;
},
async onRestartApp() {
if (this.resourceType !== 'app') return;
@@ -485,11 +576,11 @@ export default {
}
while(true) {
let error, result;
let result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
console.error('Failed to fetch app status.', e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
@@ -499,83 +590,6 @@ export default {
this.busyRestart = false;
}
},
async mounted() {
useConfirm();
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'app') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} else if (type === 'volume') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.title = result.body.name;
} else {
this.fatalError = `Unsupported type ${type}`;
return;
}
try {
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') {
this.fatalError = `Unknown type ${toParams.type}`;
return;
}
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
});
}
};
@@ -588,44 +602,21 @@ export default {
overflow: hidden;
height: 100%;
display: flex;
padding: 0 10px
padding: 0 10px;
}
.title-bar {
.side-bar-title {
text-align: center;
font-size: 20px;
margin-bottom: 20px;
color: #607d8b;
}
.title-bar > a {
color: #607d8b;
}
.title-bar > a:hover {
color: black;
text-decoration: none;
}
.main-view-col {
overflow: auto;
flex-grow: 1;
}
.dialog-header {
font-weight: 600;
font-size: 1.25rem;
}
.dialog-single-input {
display: block;
width: 100%;
margin-top: 5px;
margin-bottom: 1.5rem;
}
.dialog-single-input-submit {
margin-top: 5px;
.directory-view {
background-color: var(--pankow-color-background);
}
</style>
@@ -636,9 +627,4 @@ export default {
margin: 0;
}
/* this is actually calculated and in some situations the z-index would have to be higher but for the moment ok, needs fixing in primvue */
.p-dropdown-panel.p-component {
z-index: 5001 !important;
}
</style>
+7 -4
View File
@@ -1,18 +1,18 @@
<template>
<div class="viewer">
<TextEditor ref="textEditor"
<TextViewer ref="textEditor"
v-show="active === 'textEditor'"
:save-handler="saveHandler"
@close="onClose"
:tr="$t"
/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose"/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose" :navigation-handler="imageViewerNavigationHandler"/>
</div>
</template>
<script>
import { TextEditor, ImageViewer } from 'pankow';
import { TextViewer, ImageViewer } from 'pankow-viewers';
import { createDirectoryModel } from '../models/DirectoryModel.js';
import { sanitize } from 'pankow/utils';
@@ -22,7 +22,7 @@ export default {
name: 'Viewer',
components: {
ImageViewer,
TextEditor
TextViewer
},
data() {
return {
@@ -36,6 +36,9 @@ export default {
onClose() {
location.replace('#/home' + location.hash.slice('#/viewer'.length, location.hash.lastIndexOf('/')+1));
},
imageViewerNavigationHandler() {
// nothing to do
},
async saveHandler(item, content) {
await this.directoryModel.save(this.filePath, content);
}
+7
View File
@@ -5,6 +5,13 @@
<link rel="icon" href="/api/v1/cloudron/avatar" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Terminal</title>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
server: {
fs: {
// Allow serving files from one level up to the project root for monaco editor assets
allow: ['..']
allow: [ '../..' ]
},
},
// https://vitejs.dev/guide/build.html#multi-page-app
@@ -0,0 +1,10 @@
'use strict';
exports.up = async function (db) {
await db.runSql('ALTER TABLE apps RENAME COLUMN cpuShares to cpuQuota');
await db.runSql('ALTER TABLE apps MODIFY COLUMN cpuQuota INTEGER DEFAULT 100');
await db.runSql('UPDATE apps SET cpuQuota=?', [ 100 ]);
};
exports.down = async function () {
};
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function (db) {
await db.runSql('ALTER TABLE apps ADD COLUMN notes TEXT');
};
exports.down = async function (db) {
await db.runSql('ALTER TABLE apps DROP COLUMN notes');
};
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function (db) {
await db.runSql('ALTER TABLE apps ADD COLUMN checklistJson TEXT');
};
exports.down = async function (db) {
await db.runSql('ALTER TABLE apps DROP COLUMN checklistJson');
};
@@ -0,0 +1,14 @@
'use strict';
exports.up = async function(db) {
const result = await db.runSql('SELECT * FROM settings WHERE name=?', [ 'backup_storage' ]);
if (!result.length) return;
const storageConfig = JSON.parse(result[0].value);
delete storageConfig.limits; // this is already saved in backup_limits key
await db.runSql('UPDATE settings SET value=? WHERE name=?', [ JSON.stringify(storageConfig), 'backup_storage']);
};
exports.down = async function(/* db */) {
};
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function(db) {
await db.runSql('ALTER TABLE apps MODIFY updateTime TIMESTAMP NULL DEFAULT NULL');
await db.runSql('UPDATE apps SET updateTime=? WHERE creationTime=updateTime', [ null ]);
};
exports.down = async function(/* db */) {
};
@@ -0,0 +1,8 @@
'use strict';
exports.up = async function(db) {
await db.runSql('UPDATE apps SET notes="" WHERE notes IS NULL');
};
exports.down = async function(/* db */) {
};
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function(db) {
await db.runSql('UPDATE appPortBindings SET count=1 WHERE count IS NULL');
await db.runSql('ALTER TABLE appPortBindings MODIFY count INTEGER NOT NULL DEFAULT 1');
};
exports.down = async function(/* db */) {
};
+5 -3
View File
@@ -74,10 +74,10 @@ CREATE TABLE IF NOT EXISTS apps(
manifestJson TEXT,
accessRestrictionJson TEXT, // { users: [ ], groups: [ ] }
creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, // when the app was installed
updateTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, // when the last app update was done
updateTime TIMESTAMP NULL DEFAULT NULL, // when the last app update was done . Without the first NULL, it comes current time when assigned null!
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, // when this db record was updated (useful for UI caching)
memoryLimit BIGINT DEFAULT 0,
cpuShares INTEGER DEFAULT 512,
cpuQuota INTEGER DEFAULT 100,
xFrameOptions VARCHAR(512),
sso BOOLEAN DEFAULT 1, // whether user chose to enable SSO
debugModeJson TEXT, // options for development mode
@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS apps(
inboxName VARCHAR(128), // mailbox of this app
inboxDomain VARCHAR(128), // mailbox domain of this app
label VARCHAR(128), // display name
notes TEXT, // free form notes for admins
tagsJson VARCHAR(2048), // array of tags
storageVolumeId VARCHAR(128),
storageVolumePrefix VARCHAR(128),
@@ -103,6 +104,7 @@ CREATE TABLE IF NOT EXISTS apps(
icon MEDIUMBLOB,
crontab TEXT,
upstreamUri VARCHAR(256) DEFAULT "",
checklistJson TEXT, // checklist for admins
FOREIGN KEY(mailboxDomain) REFERENCES domains(domain),
FOREIGN KEY(inboxDomain) REFERENCES domains(domain),
@@ -116,7 +118,7 @@ CREATE TABLE IF NOT EXISTS appPortBindings(
type VARCHAR(8) NOT NULL DEFAULT "tcp",
environmentVariable VARCHAR(128) NOT NULL,
appId VARCHAR(128) NOT NULL,
count INTEGER DEFAULT 1,
count INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY(appId) REFERENCES apps(id),
PRIMARY KEY(hostPort));

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