Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfe9ee457d | |||
| a034b70449 | |||
| 4226654772 | |||
| 4ea8ab08a3 | |||
| 702fc120af | |||
| 9453084481 | |||
| c6dbbc4135 | |||
| ddc53bcb6f | |||
| e50509ac45 | |||
| 2ddba469b2 | |||
| 4e1b2ccbaa | |||
| e0b8a2400a | |||
| 151ba569a7 | |||
| 2cb755fe44 | |||
| eeef49fd19 | |||
| 6b2626120c | |||
| e77ab26516 | |||
| dbaf6c6ce2 | |||
| 5e295f9f1e | |||
| 8d3b655517 | |||
| 64cefd52c8 | |||
| edb92ed0a5 | |||
| a8513cc0fa | |||
| 20d4ce6632 | |||
| d8c3ce30ca | |||
| d894de0784 | |||
| 572bd19df6 | |||
| 4fd399eae9 | |||
| f7f55710d1 | |||
| 18815b97ce | |||
| c4fce32a6a | |||
| 9ed5f43ea1 | |||
| 232bce0a2d | |||
| 27f975f3c5 | |||
| 5b834b4396 | |||
| 52b46e2b3e | |||
| 044fb72da9 | |||
| 0cf911bcdd | |||
| 829512dd13 | |||
| fa886c71b8 | |||
| 21191bdc50 | |||
| 1bf2fe16a2 | |||
| c35543af92 | |||
| 9bb71bd066 | |||
| f24e4f291d | |||
| 32ab9a9d32 | |||
| 8b520dec48 | |||
| 70c539ac4d | |||
| 610651066a | |||
| aaa750dbbc | |||
| a518ee83cc | |||
| de84b5113c | |||
| 2ea7847d4f | |||
| 0650fca1cf | |||
| 1b5bd0d379 | |||
| 5b6f796606 | |||
| 9d6a755486 | |||
| 9470654394 | |||
| 28feadd6c5 | |||
| af3ed04b7f | |||
| 2da99673cd | |||
| 476adcb029 | |||
| b2c8f87276 | |||
| bd4e132709 | |||
| fa8fcf8761 | |||
| 8e92b53d9f | |||
| 6f90bd3db0 | |||
| a261d8b754 | |||
| 9643b7ed1b | |||
| ec191d51bc | |||
| a5452e4b15 | |||
| 8522802f85 | |||
| 6f2e3afe07 | |||
| 70dfb41d95 | |||
| 34f04828c5 | |||
| a78799973d | |||
| 1797148951 | |||
| 67caa89591 | |||
| e3a88e9f5b | |||
| e9910c9b95 | |||
| 45e058bdc1 | |||
| 9af5404921 | |||
| 5c4ca1b699 | |||
| b6827736db | |||
| aada3f3979 | |||
| dc07078fd4 | |||
| ae8278bdb3 | |||
| 286de8cdcb | |||
| ca11d5af94 | |||
| fb04f78112 | |||
| 75fa2dfd67 | |||
| 137267e604 | |||
| 642487f4c5 | |||
| 783ad9ecda | |||
| 0213a368b9 | |||
| f1e7594b79 | |||
| 02fd52e366 | |||
| 2d5e0a51bd | |||
| 1cd82dcd4c | |||
| 5ba30d0236 | |||
| c0ea5c31eb | |||
| adee5fa25f | |||
| f9af84fd85 | |||
| 41cb381a2e | |||
| 50ca07bfb8 | |||
| 07732310c1 | |||
| 854661e2d4 | |||
| 8cac83ed98 | |||
| 5ee8e9da80 | |||
| f5c81f5882 | |||
| a415b70adf | |||
| 800a7e26e9 | |||
| 1bc9dc30f6 | |||
| 7d538ee1b8 | |||
| ac5f4cca19 | |||
| 54a5d5b9aa | |||
| 5c4ec5afc0 | |||
| 5bd6001f95 | |||
| 0fb8914b67 | |||
| 1f6ac49686 | |||
| 42887fb1d9 | |||
| f14a7808cb | |||
| a781a46f13 | |||
| 6941a12314 | |||
| f0e70a97bc | |||
| c59e3ef4ae | |||
| 2bfdc7c1ac | |||
| d831e7d765 | |||
| fe8ef5b922 | |||
| 2c150eee33 | |||
| a4d6bafe1a | |||
| 78017b8adb | |||
| ea822f66ca | |||
| a55adf12db | |||
| 84c016490c | |||
| bb7056d614 | |||
| 462b490d05 | |||
| 084050bb2f | |||
| 8d2ea7e736 | |||
| fe8d5b0d3e | |||
| de724319aa | |||
| ac91b417c3 | |||
| 229863d7ff | |||
| 8dcb3f2f85 | |||
| 15c8f84960 | |||
| f37dd03e4b | |||
| 82c97f7e1c | |||
| 91078f7a7e | |||
| d2775956e0 | |||
| 00b52fa3af | |||
| 1ac0ed3c18 | |||
| 6ec8246b46 | |||
| f5978a524d | |||
| 72030ee8fc | |||
| d6a4dd6965 | |||
| 8aa5dc85af | |||
| 5c7f99c0ee | |||
| 847cb91759 | |||
| 9e92d08261 | |||
| bf8e03aa0c | |||
| fcd05f3bb4 | |||
| a14dfc171d | |||
| b8b445eb24 | |||
| fbf4a53a1b | |||
| 0c7e810bd3 | |||
| 0502779a29 | |||
| 576d9ca894 | |||
| d8771509cd | |||
| b139749198 | |||
| bdcb5c502c | |||
| dc72df1dbd | |||
| 8be834d0c8 | |||
| c995454f69 | |||
| 854e0ebe3f | |||
| f01d2631dd | |||
| 60f8cdf3b4 | |||
| 8e5bf14623 | |||
| b063ebd6d7 | |||
| eb7d7a2d1b | |||
| f9ee088592 | |||
| 1f32d4b4dd | |||
| d3b4c2f394 | |||
| 41c00eda74 | |||
| 155af33b0c | |||
| b289146aeb | |||
| d2e32a4fd0 | |||
| 6631c95166 | |||
| 7adabcc203 | |||
| de35a935a6 | |||
| d3d668d930 | |||
| 1f60c6dd21 | |||
| 1431700642 | |||
| 12a1de56fd |
@@ -2980,3 +2980,71 @@
|
||||
* add ephemeral port warning
|
||||
* rsync: fix integrity computation
|
||||
|
||||
[9.0.2]
|
||||
* backupsite: only owner can add a site
|
||||
* remove max-height from the users view and groups view tables
|
||||
* backups: fix listing when stats is null
|
||||
* graphs: fix detection of rootfs block device
|
||||
* sidebar: ldap/openid/directory should not be visible to non-admins
|
||||
* sidebar: email domains, eventlog, settings is only for admins
|
||||
* reload dashboard on Cloudron version change
|
||||
* Always start with a fresh domains list for the apps filter
|
||||
* sysinfo: fallback to product family if product vendor is empty
|
||||
* archive: display the site name of latest backup
|
||||
* graphs: fix flickering of disk graph item
|
||||
* graphs: fix issue with live graph time calculation
|
||||
|
||||
[9.0.3]
|
||||
* Fix submit state for login form
|
||||
* Avoid flickering of SystemUpdate view when update is busy
|
||||
* backuptask: fix crash when accessing stats of old backups
|
||||
* backup sites: fix listing when status call errors
|
||||
* backups: display mail backup stats
|
||||
* Add missing autocomplete attributes on forms
|
||||
* Refresh backup site status and task in the background
|
||||
* Hide non-owner actions for backup sites
|
||||
* Move app start/stop back to the main toolbar
|
||||
* Fix styling in public page
|
||||
* network: fix ip caching bug
|
||||
* Change default footer to not have the forum link
|
||||
* Fix troubleshooting tool
|
||||
* Give domains list a larger max-height
|
||||
* Make app error compatible with previous releases
|
||||
|
||||
[9.0.4]
|
||||
* filemanager: fix missing translations
|
||||
* display backup duration
|
||||
* add hetznercloud DNS provider
|
||||
|
||||
[9.0.5]
|
||||
* access control/operators: remove deleted users and groups
|
||||
* backupcleaner: fix scoping of cleanup by site id
|
||||
* Use normal buttons for app start/stop
|
||||
* site schedule: Fix hourly display
|
||||
|
||||
[9.0.6]
|
||||
* Autofocus search in appstore view
|
||||
* All settings in sidebar should be same icon
|
||||
* Make backup content list a TableView so we can sort it by size and fileCount
|
||||
* Fix filemanager for custom apps
|
||||
* Sort apps in the grid by label
|
||||
* Filter dropdowns are searchable with more than 10 entries
|
||||
* Show app icons in the grid in grayscale if app is stopped
|
||||
* Support wildcard domain aliases in app location
|
||||
|
||||
[9.0.7]
|
||||
* externalldap: only set group members if they changed
|
||||
* Fix issue where backups remote paths were incorrectly migrated
|
||||
|
||||
[9.0.8]
|
||||
* Add explicit option to disable automatic backups
|
||||
* backups: show same filesystem warning
|
||||
* Fix tgz app backup download
|
||||
* Fix mailbox usage and quota sorting
|
||||
* Give sshfs identity files unique filenames across mounts
|
||||
* Do not share relay provider setting with view and form
|
||||
* cloudflare: ensure defaultProxyStatus in older configs
|
||||
* filter: fix domain search to include redirect/alias/secondary domains
|
||||
* Use full URLs for page preview icons and favicon
|
||||
* email: fix masquerade toggle
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
|
||||
## Translations
|
||||
|
||||
This documents the convention used for the text in the UI.
|
||||
|
||||
### Tale of Two Cases
|
||||
|
||||
**Title Case**
|
||||
|
||||
All words are capitalized. In title case, articles (a/an/the), conjunctions (and/but/or/...)
|
||||
and prepositions (on/at/...) inside a phrase are not capitalized. Everything else is capitalized
|
||||
- noun, pronoun, verb, adverb.
|
||||
|
||||
Examples:
|
||||
|
||||
* "Sign In to Your Account"
|
||||
* "Terms and Conditions"
|
||||
* "Getting Started with GraphQL"
|
||||
* "Between You and Me"
|
||||
|
||||
**Sentence Case**
|
||||
|
||||
Only first word is capitalized.
|
||||
|
||||
### UI Conventions
|
||||
|
||||
| Element | Recommended Style | Example |
|
||||
| -------------- | ---------------------- | -------------------------------- |
|
||||
| Headings | Title Case | Manage Account |
|
||||
| Sub heading | Title Case | Create Admin Account |
|
||||
| Form Labels | Title Case | Email Address |
|
||||
| Buttons | Sentence Case | Save changes |
|
||||
| Radio Buttons | Sentence Case | Option one / Option two |
|
||||
| Checkbox | Sentence Case | Use CIFS encryption |
|
||||
| Menu action | Sentence Case | Select all |
|
||||
| Switches | Sentence Case | Allow users to edit email |
|
||||
| Descriptions | Sentence case | Enter your password to continue. |
|
||||
| Tooltips | Sentence case | Click to edit. |
|
||||
| Error Messages | Sentence case | Password is too short |
|
||||
| Notifications | Sentence case | Settings saved successfully. |
|
||||
| Legend (graph) | Sentence case | Docker volume, Box data. |
|
||||
| Placeholders | Sentence case | Comma separated IPs or subnets |
|
||||
|
||||
Hints in brackets are small case. Like "(comma separated)".
|
||||
|
||||
### Full Stops
|
||||
|
||||
Sentence fragments like form hints and tooltips (which are always visible) do not need a full stop.
|
||||
All other full sentences do.
|
||||
|
||||
Description has a full stop unless it's a hint/phrase.
|
||||
|
||||
instructional heading in dialogs (like the object being configured) should not have a full stop.
|
||||
|
||||
Switch UI description does not have a fullstop.
|
||||
|
||||
Setting item description does not need a fullstop (usually).
|
||||
|
||||
Checkbox labels do not have a full stop at the end.
|
||||
|
||||
No full stop → short labels, commands, headings, or action text (“Configure Service {{serviceName}}”).
|
||||
|
||||
Full stop → descriptive text or sentences explaining a setting (“The IPv4 address used for DNS A records.”).
|
||||
|
||||
### Dialog Buttons
|
||||
|
||||
'Add' for addition
|
||||
'Cancel' to cancel
|
||||
'Save' for edit/update
|
||||
'Remove' for non-destructive/less destructive things (app password remove)
|
||||
'Delete' for destructive (user delete)
|
||||
|
||||
'Close' - Only for dialogs with the only button
|
||||
|
||||
### Dialog Text
|
||||
|
||||
When asking for confirmation simply ask 'Remove app password "xxx"' . Don't use "really"
|
||||
or other emotional terms. Quote the password/domain name.
|
||||
|
||||
In general, we put just "Delete User" in Title and provide the username in the context.
|
||||
|
||||
Title = action (what you’re doing)
|
||||
Description = context (to whom it applies)
|
||||
|
||||
### Description Text
|
||||
|
||||
| Context | Verb form | Example |
|
||||
| --------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||
| **Action / Button / Instruction** | **Imperative** → “Add” | Button: **Add**, Tooltip: “Add a new link” |
|
||||
| **Section / View description** | **Imperative** → “Add” | Description: **Adds shortcuts to external services on the dashboard.** |
|
||||
|
||||
We use plural when possible. "Admins can ..." , "Operators can ..."
|
||||
|
||||
Generated
+242
-244
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.5.1",
|
||||
"@cloudron/pankow": "^3.5.9",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
@@ -15,18 +15,18 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.2",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"marked": "^16.4.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"marked": "^17.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.1.9",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -39,21 +39,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.4"
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -63,22 +63,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudron/pankow": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.1.tgz",
|
||||
"integrity": "sha512-xy5B1dqB2F90EvVguh5aMeTv8zy79p9VffU9CRf0ekXtGxsGBYyEeQoSYGaeud1M6Vs93SQ9RkGe9YtVG+WHKA==",
|
||||
"version": "3.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.9.tgz",
|
||||
"integrity": "sha512-59aAGwAdOGwSi3csh+jf6+cOEB5IyJrgppooyfj8K031Go145CmN3rD7J1eeVhZRDBa9zjbHNTSqs/rMqkwyEA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
@@ -526,12 +526,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
|
||||
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
@@ -540,21 +540,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
|
||||
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.16.0"
|
||||
"@eslint/core": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
|
||||
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
@@ -587,9 +587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
|
||||
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
||||
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -599,21 +599,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
|
||||
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
|
||||
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.16.0",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1048,53 +1048,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
|
||||
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
|
||||
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/shared": "3.5.22",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.24",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
|
||||
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
|
||||
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
|
||||
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -1103,53 +1103,53 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
||||
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
||||
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
||||
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.22"
|
||||
"vue": "3.5.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
|
||||
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
|
||||
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-attach": {
|
||||
@@ -1301,9 +1301,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
@@ -1457,24 +1457,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.0",
|
||||
"@eslint/config-helpers": "^0.4.0",
|
||||
"@eslint/core": "^0.16.0",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@eslint/plugin-kit": "^0.4.0",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -1517,9 +1516,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-vue": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.0.tgz",
|
||||
"integrity": "sha512-7BZHsG3kC2vei8F2W8hnfDi9RK+cv5eKPMvzBdrl8Vuc0hR5odGQRli8VVzUkrmUHkxFEm4Iio1r5HOKslO0Aw==",
|
||||
"version": "10.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.1.tgz",
|
||||
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
@@ -1918,18 +1917,18 @@
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.0.tgz",
|
||||
"integrity": "sha512-CTPAcRBq57cn3R8n3hwc2REddc28hjR7RzDXQ+lXLmMJYqn20BaI2cGw6QjgZGIgVfp2Wdfw4aMzgNteQ6qJgQ==",
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
|
||||
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
@@ -2384,9 +2383,9 @@
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
||||
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -2503,16 +2502,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
|
||||
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
"@vue/runtime-dom": "3.5.22",
|
||||
"@vue/server-renderer": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-sfc": "3.5.24",
|
||||
"@vue/runtime-dom": "3.5.24",
|
||||
"@vue/server-renderer": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
@@ -2569,9 +2568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
||||
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
@@ -2580,7 +2579,7 @@
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
@@ -2633,31 +2632,31 @@
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.28.4"
|
||||
"@babel/types": "^7.28.5"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"requires": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
}
|
||||
},
|
||||
"@cloudron/pankow": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.1.tgz",
|
||||
"integrity": "sha512-xy5B1dqB2F90EvVguh5aMeTv8zy79p9VffU9CRf0ekXtGxsGBYyEeQoSYGaeud1M6Vs93SQ9RkGe9YtVG+WHKA==",
|
||||
"version": "3.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.9.tgz",
|
||||
"integrity": "sha512-59aAGwAdOGwSi3csh+jf6+cOEB5IyJrgppooyfj8K031Go145CmN3rD7J1eeVhZRDBa9zjbHNTSqs/rMqkwyEA==",
|
||||
"requires": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
@@ -2836,27 +2835,27 @@
|
||||
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="
|
||||
},
|
||||
"@eslint/config-array": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
|
||||
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"requires": {
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
}
|
||||
},
|
||||
"@eslint/config-helpers": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
|
||||
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
|
||||
"requires": {
|
||||
"@eslint/core": "^0.16.0"
|
||||
"@eslint/core": "^0.17.0"
|
||||
}
|
||||
},
|
||||
"@eslint/core": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
|
||||
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"requires": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
}
|
||||
@@ -2878,21 +2877,21 @@
|
||||
}
|
||||
},
|
||||
"@eslint/js": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
|
||||
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
||||
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="
|
||||
},
|
||||
"@eslint/object-schema": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="
|
||||
},
|
||||
"@eslint/plugin-kit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
|
||||
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
|
||||
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
|
||||
"requires": {
|
||||
"@eslint/core": "^0.16.0",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"levn": "^0.4.1"
|
||||
}
|
||||
},
|
||||
@@ -3114,49 +3113,49 @@
|
||||
}
|
||||
},
|
||||
"@vue/compiler-core": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
|
||||
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
|
||||
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
|
||||
"requires": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/shared": "3.5.22",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.24",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"@vue/compiler-dom": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
|
||||
"requires": {
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"@vue/compiler-sfc": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
|
||||
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
|
||||
"requires": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"@vue/compiler-ssr": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
|
||||
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
|
||||
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
|
||||
"requires": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"@vue/devtools-api": {
|
||||
@@ -3165,46 +3164,46 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||
},
|
||||
"@vue/reactivity": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
||||
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"requires": {
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"@vue/runtime-core": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
||||
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"requires": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"@vue/runtime-dom": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"requires": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"@vue/server-renderer": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
||||
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"requires": {
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"@vue/shared": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
|
||||
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w=="
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
|
||||
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A=="
|
||||
},
|
||||
"@xterm/addon-attach": {
|
||||
"version": "0.11.0",
|
||||
@@ -3310,9 +3309,9 @@
|
||||
}
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"requires": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
}
|
||||
@@ -3417,23 +3416,22 @@
|
||||
}
|
||||
},
|
||||
"eslint": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.0",
|
||||
"@eslint/config-helpers": "^0.4.0",
|
||||
"@eslint/core": "^0.16.0",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@eslint/plugin-kit": "^0.4.0",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -3488,9 +3486,9 @@
|
||||
}
|
||||
},
|
||||
"eslint-plugin-vue": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.0.tgz",
|
||||
"integrity": "sha512-7BZHsG3kC2vei8F2W8hnfDi9RK+cv5eKPMvzBdrl8Vuc0hR5odGQRli8VVzUkrmUHkxFEm4Iio1r5HOKslO0Aw==",
|
||||
"version": "10.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.1.tgz",
|
||||
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -3718,17 +3716,17 @@
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||
},
|
||||
"magic-string": {
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"requires": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"marked": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.0.tgz",
|
||||
"integrity": "sha512-CTPAcRBq57cn3R8n3hwc2REddc28hjR7RzDXQ+lXLmMJYqn20BaI2cGw6QjgZGIgVfp2Wdfw4aMzgNteQ6qJgQ=="
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
|
||||
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg=="
|
||||
},
|
||||
"micromatch": {
|
||||
"version": "4.0.8",
|
||||
@@ -4009,9 +4007,9 @@
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
|
||||
},
|
||||
"vite": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
||||
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"requires": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4044,15 +4042,15 @@
|
||||
}
|
||||
},
|
||||
"vue": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
|
||||
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
|
||||
"requires": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
"@vue/runtime-dom": "3.5.22",
|
||||
"@vue/server-renderer": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-sfc": "3.5.24",
|
||||
"@vue/runtime-dom": "3.5.24",
|
||||
"@vue/server-renderer": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
},
|
||||
"vue-eslint-parser": {
|
||||
@@ -4081,9 +4079,9 @@
|
||||
}
|
||||
},
|
||||
"vue-router": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
||||
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
|
||||
"requires": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.5.1",
|
||||
"@cloudron/pankow": "^3.5.9",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
@@ -16,17 +16,17 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.2",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"marked": "^16.4.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"marked": "^17.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.1.9",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,6 @@
|
||||
{
|
||||
"apps": {
|
||||
"tagsFilterHeaderAll": "Semua Tag",
|
||||
"adminPageActionTooltip": "Halaman Admin",
|
||||
"domainsFilterHeader": "Semua Domain",
|
||||
"groupsFilterHeader": "Semua Grup",
|
||||
"addAppAction": "Tambah Aplikasi",
|
||||
"title": "Aplikasi Saya",
|
||||
"tagsFilterHeader": "Tag: {{ tags }}"
|
||||
"title": "Aplikasi Saya"
|
||||
},
|
||||
"main": {
|
||||
"dialog": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,6 @@
|
||||
"rebootDialog": {
|
||||
"title": "本当にサーバーを再起動しますか?"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopyBackupId": "バックアップIDをクリックしてコピー",
|
||||
"clickToCopy": "クリックしてコピー",
|
||||
"copied": "クリップボードにコピーしました"
|
||||
},
|
||||
"action": {
|
||||
"logs": "ログ",
|
||||
"reboot": "再起動"
|
||||
@@ -15,10 +10,6 @@
|
||||
"table": {
|
||||
"date": "日付"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "次",
|
||||
"prev": "前"
|
||||
},
|
||||
"displayName": "表示名",
|
||||
"username": "ユーザー名",
|
||||
"dialog": {
|
||||
@@ -32,14 +23,7 @@
|
||||
"offline": "Cloudronはオフラインです。再接続中…"
|
||||
},
|
||||
"apps": {
|
||||
"tagsFilterHeaderAll": "タグ一覧",
|
||||
"domainsFilterHeader": "ドメイン一覧",
|
||||
"tagsFilterHeader": "タグ: {{ tags }}",
|
||||
"searchPlaceholder": "アプリを探す",
|
||||
"adminPageActionTooltip": "管理者ページ",
|
||||
"infoActionTooltip": "情報",
|
||||
"logsActionTooltip": "ログ",
|
||||
"configActionTooltip": "設定",
|
||||
"noAccess": {
|
||||
"description": "アクセス権のあるアプリは、ここにに表示されます。",
|
||||
"title": "アプリへのアクセス権がありません。"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,33 +15,11 @@
|
||||
"userManagementNone": "Ta aplikacja posiada własne zarządzanie użytkownikami.",
|
||||
"userManagement": "Zarządanie użytkownikami",
|
||||
"manualWarning": "Manualnie dodaj rekord A dla <b>{{ location }}</b> do publicznego IP tego Cloudrona",
|
||||
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>.",
|
||||
"lowOnResources": "Ten Cloudron jest blisko wyczerpania dostępnych zasobów."
|
||||
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>."
|
||||
},
|
||||
"unstable": "Niestabilne",
|
||||
"appMissing": "Szukasz innej aplikacji? Daj nam znać.",
|
||||
"noAppsFound": "Nie znaleziono żadnych aplikacji.",
|
||||
"searchPlaceholder": "Szukaj alternatyw jak Github, Dropbox, Slack, Trello, ...",
|
||||
"category": {
|
||||
"vpn": "VPN",
|
||||
"wiki": "Wiki",
|
||||
"project": "Zarządzanie projetkami",
|
||||
"sync": "Synchronizacja plików",
|
||||
"learning": "Nauka",
|
||||
"notes": "Notatki",
|
||||
"media": "Media",
|
||||
"git": "Hostowanie kodu",
|
||||
"hosting": "Web Hosting",
|
||||
"game": "Gry",
|
||||
"email": "Email",
|
||||
"finance": "Finanse",
|
||||
"gallery": "Galeria",
|
||||
"forum": "Forum",
|
||||
"crm": "CRM",
|
||||
"document": "Dokumenty",
|
||||
"blog": "Blog",
|
||||
"chat": "Czat",
|
||||
"analytics": "Analityka",
|
||||
"newApps": "Nowe aplikacje",
|
||||
"popular": "Popularne",
|
||||
"all": "Wszystko"
|
||||
@@ -52,23 +30,12 @@
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Zrestartuj teraz",
|
||||
"description": "Restartuj serwer by sfinalizowac instalacje aktualizacji bezpieczeństwa lub w przypadku nieoczekiwanych zachowań. Wszytskie usługi i aplikacje aktywne na tym Cloudronie zostaną automatycznie uruchomione ponownie po restarcie.",
|
||||
"warning": "Restart serwera spowoduje tymczasową niedostepność wszystkich aplikacji zainstalowanych na tym Cloudronie!",
|
||||
"title": "Na pewno zrestartować serwer?"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopyBackupId": "Kliknij by skopiowac Backup ID",
|
||||
"clickToCopy": "Kliknij by skopiować",
|
||||
"copied": "Skopiowano do schowka"
|
||||
},
|
||||
"action": {
|
||||
"logs": "Logi",
|
||||
"reboot": "Restart"
|
||||
},
|
||||
"pagination": {
|
||||
"perPageSelector": "Pokazuj {{ n }} na stronie",
|
||||
"prev": "Poprzednia",
|
||||
"next": "Następna"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
@@ -86,15 +53,7 @@
|
||||
"offline": "Cloudron jest niedostępny. Odnawiam połączenie…"
|
||||
},
|
||||
"apps": {
|
||||
"domainsFilterHeader": "Wszytskie domeny",
|
||||
"tagsFilterHeaderAll": "Wszystkie tagi",
|
||||
"tagsFilterHeader": "Tagi: {{ tags }}",
|
||||
"stateFilterHeader": "Wszytskie stany",
|
||||
"searchPlaceholder": "Szukaj Aplikacji",
|
||||
"adminPageActionTooltip": "Panel Administratora",
|
||||
"infoActionTooltip": "Informacje",
|
||||
"logsActionTooltip": "Logi",
|
||||
"configActionTooltip": "Konfiguracja",
|
||||
"noAccess": {
|
||||
"description": "Po uzyskaniu dostępu będą one widoczne tutaj.",
|
||||
"title": "Nie masz obecnie dostępu do żadnych aplikacji."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,6 @@
|
||||
"main": {
|
||||
"logout": "නික්මෙන්න",
|
||||
"actions": "ක්රියාමාර්ග",
|
||||
"prettyDate": {
|
||||
"minutesAgo": "විනාඩි {{ m }} ට පෙර",
|
||||
"hoursAgo": "හෝරා {{ h }} ට පෙර",
|
||||
"justNow": "මේ දැන්",
|
||||
"yeserday": "ඊයේ"
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "අවලංගු",
|
||||
"save": "සුරකින්න",
|
||||
@@ -19,10 +13,6 @@
|
||||
"table": {
|
||||
"date": "දිනය"
|
||||
},
|
||||
"pagination": {
|
||||
"prev": "පෙර",
|
||||
"next": "ඊළඟ"
|
||||
},
|
||||
"searchPlaceholder": "සොයන්න",
|
||||
"multiselect": {
|
||||
"select": "තෝරන්න"
|
||||
@@ -30,35 +20,18 @@
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
"chat": "සම්භාෂණය",
|
||||
"learning": "ඉගෙනීම",
|
||||
"project": "ව්යාපෘති කළමනාකරණය",
|
||||
"all": "සියල්ල",
|
||||
"popular": "ජනප්රිය",
|
||||
"newApps": "නව යෙදුම්",
|
||||
"analytics": "විශ්ලේෂ",
|
||||
"document": "ලේඛන",
|
||||
"crm": "පා.ස.ක. (CRM)",
|
||||
"finance": "මූල්ය",
|
||||
"email": "වි-තැපෑල",
|
||||
"game": "ක්රීඩා",
|
||||
"media": "මාධ්ය",
|
||||
"notes": "සටහන්"
|
||||
"newApps": "නව යෙදුම්"
|
||||
},
|
||||
"title": "යෙදුම් ගබඩාව",
|
||||
"installDialog": {
|
||||
"location": "ස්ථානය",
|
||||
"groups": "සමූහ"
|
||||
},
|
||||
"accountDialog": {
|
||||
"password": "මුරපදය",
|
||||
"email": "වි-තැපෑල"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"title": "මාගේ යෙදුම්",
|
||||
"infoActionTooltip": "තොරතුරු",
|
||||
"searchPlaceholder": "යෙදුම් සොයන්න",
|
||||
"domainsFilterHeader": "සියලුම වසම්"
|
||||
"searchPlaceholder": "යෙදුම් සොයන්න"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+27
-12
@@ -74,10 +74,6 @@ const VIEWS = Object.freeze({
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
|
||||
function onOnline() {
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
fetcher.globalOptions.errorHook = (error) => {
|
||||
// network error, request killed by browser
|
||||
if (error instanceof TypeError) {
|
||||
@@ -109,6 +105,7 @@ const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
|
||||
const ready = ref(false);
|
||||
const view = ref('');
|
||||
const profile = ref({});
|
||||
const dashboardDomain = ref('');
|
||||
const subscription = ref({
|
||||
plan: {},
|
||||
});
|
||||
@@ -219,8 +216,24 @@ async function refreshProfile() {
|
||||
async function refreshConfigAndFeatures() {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const currentVersion = localStorage.getItem('version');
|
||||
if (currentVersion === null) {
|
||||
localStorage.setItem('version', result.version);
|
||||
} else if (result.version !== currentVersion) {
|
||||
console.log('Dashboard version changed, reloading');
|
||||
localStorage.setItem('version', result.version);
|
||||
window.location.reload(true);
|
||||
}
|
||||
|
||||
config.value = result;
|
||||
features.value = result.features;
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
}
|
||||
|
||||
async function onOnline() {
|
||||
ready.value = true;
|
||||
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
|
||||
}
|
||||
|
||||
provide('subscriptionRequiredDialog', subscriptionRequiredDialog);
|
||||
@@ -228,6 +241,7 @@ provide('features', features);
|
||||
provide('profile', profile);
|
||||
provide('refreshProfile', refreshProfile);
|
||||
provide('refreshFeatures', refreshConfigAndFeatures);
|
||||
provide('dashboardDomain', dashboardDomain);
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await provisionModel.status();
|
||||
@@ -252,13 +266,14 @@ onMounted(async () => {
|
||||
await refreshConfigAndFeatures();
|
||||
|
||||
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
|
||||
if (document.querySelector('link[rel="icon"]')) document.querySelector('link[rel="icon"]').href = `${API_ORIGIN}/api/v1/cloudron/avatar?ts=${Date.now()}`;
|
||||
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.hash = `/${VIEWS.PROFILE}?setup2fa`;
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
|
||||
console.log(`Cloudron dashboard v${config.value.version}`);
|
||||
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
@@ -287,20 +302,20 @@ onMounted(async () => {
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.USERS]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USERS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.USERS" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.GROUPS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.GROUPS" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.LDAP }" :href="VIEWS.LDAP" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> {{ $t('ldap.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.OPENID }" :href="VIEWS.OPENID" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> {{ $t('oidc.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('userdirectory.settings.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.LDAP }" v-show="profile.isAtLeastAdmin" :href="VIEWS.LDAP" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> LDAP</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.OPENID }" v-show="profile.isAtLeastAdmin" :href="VIEWS.OPENID" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> OpenID</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('userdirectory.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup(SIDEBAR_GROUPS.EMAIL)"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.EMAIL] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.EMAIL]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_DOMAINS || view === VIEWS.EMAIL_DOMAIN }" :href="VIEWS.EMAIL_DOMAINS" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_DOMAINS || view === VIEWS.EMAIL_DOMAIN }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_DOMAINS" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILBOXES }" :href="VIEWS.MAILBOXES" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILINGLISTS }" :href="VIEWS.MAILINGLISTS" @click="onSidebarClose()"><i class="fa fa-fw-solid fa-envelopes-bulk"></i> {{ $t('email.incoming.mailinglists.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_EVENTLOG }" :href="VIEWS.EMAIL_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" :href="VIEWS.EMAIL_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('emails.settings.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_EVENTLOG }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('emails.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -320,7 +335,7 @@ onMounted(async () => {
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.SYSTEM)"><i class="fa fa-server fa-fw"></i> {{ $t('system.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOCKER }" :href="VIEWS.DOCKER" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> {{ $t('docker.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOCKER }" :href="VIEWS.DOCKER" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> Docker</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVICES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVICES" @click="onSidebarClose()"><i class="fa fa-diagram-project fa-fw"></i> {{ $t('services.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_EVENTLOG }" :href="VIEWS.SYSTEM_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_UPDATE }" :href="VIEWS.SYSTEM_UPDATE" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { FormGroup, Radiobutton, MultiSelect } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
import { ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const props = defineProps([ 'manifest', 'error', 'hideOptionalSsoOption' ]);
|
||||
const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
manifest: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sso: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
installation: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accessRestrictionOption = defineModel('option');
|
||||
const accessRestriction = defineModel('acl');
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
const optionalSso = !!props.manifest.optionalSso;
|
||||
const cloudronAuth = !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']) && !props.hideOptionalSsoOption;
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
const optionalSso = computed(() => {
|
||||
return !!props.manifest.optionalSso && props.installation;
|
||||
});
|
||||
const cloudronAuth = computed(() => {
|
||||
return !(!props.installation && !props.sso) && !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -101,10 +101,12 @@ function onReset() {
|
||||
|
||||
async function onRevokeToken(apiToken) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('profile.removeApiToken.title', { name: apiToken.name }),
|
||||
title: t('profile.removeApiToken.title'),
|
||||
message: t('profile.removeApiToken.description', { name: apiToken.name }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -128,9 +130,9 @@ onMounted(async () => {
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createApiToken.title')"
|
||||
:confirm-label="addedToken ? '' : $t('profile.createApiToken.generateToken')"
|
||||
:confirm-label="addedToken ? '' : $t('main.action.add')"
|
||||
confirm-style="primary"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitAddApiToken()"
|
||||
@close="onReset()"
|
||||
@@ -154,7 +156,8 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('profile.createApiToken.allowedIpRanges') }}</label>
|
||||
<div class="has-error" v-show="tokenAllowedIpRangesError">{{ tokenAllowedIpRangesError }}</div>
|
||||
<TextInput v-model="tokenAllowedIpRanges" :placeholder="$t('profile.apiTokens.allowedIpRangesPlaceholder')" />
|
||||
<TextInput v-model="tokenAllowedIpRanges" />
|
||||
<small class="helper-text">{{ $t('profile.apiTokens.allowedIpRangesPlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, FormGroup, TextInput, PasswordInput, Checkbox } from '@cloudron/pankow';
|
||||
import { s3like } from '../utils.js';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupProviderForm from './BackupProviderForm.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_WASABI } from '../constants.js';
|
||||
@@ -31,80 +31,102 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
let backupPath = remotePath.value;
|
||||
const backupConfig = {};
|
||||
const config = {};
|
||||
|
||||
// only set provider specific fields, this will clear them in the db
|
||||
if (s3like(provider.value)) {
|
||||
backupConfig.bucket = providerConfig.value.bucket;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.accessKeyId = providerConfig.value.accessKeyId;
|
||||
backupConfig.secretAccessKey = providerConfig.value.secretAccessKey;
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.accessKeyId = providerConfig.value.accessKeyId;
|
||||
config.secretAccessKey = providerConfig.value.secretAccessKey;
|
||||
|
||||
if (providerConfig.value.endpoint) backupConfig.endpoint = providerConfig.value.endpoint;
|
||||
if (providerConfig.value.endpoint) config.endpoint = providerConfig.value.endpoint;
|
||||
|
||||
if (provider.value === 's3') {
|
||||
if (providerConfig.value.region) backupConfig.region = providerConfig.value.region;
|
||||
delete backupConfig.endpoint;
|
||||
if (providerConfig.value.region) config.region = providerConfig.value.region;
|
||||
delete config.endpoint;
|
||||
} else if (provider.value === 'minio' || provider.value === 's3-v4-compat') {
|
||||
backupConfig.region = providerConfig.value.region || 'us-east-1';
|
||||
backupConfig.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
|
||||
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
|
||||
config.region = providerConfig.value.region || 'us-east-1';
|
||||
config.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
|
||||
config.s3ForcePathStyle = true; // might want to expose this in the UI
|
||||
} else if (provider.value === 'exoscale-sos') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'wasabi') {
|
||||
backupConfig.region = REGIONS_WASABI.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_WASABI.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'scaleway-objectstorage') {
|
||||
backupConfig.region = REGIONS_SCALEWAY.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_SCALEWAY.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'linode-objectstorage') {
|
||||
backupConfig.region = REGIONS_LINODE.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_LINODE.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'ovh-objectstorage') {
|
||||
backupConfig.region = REGIONS_OVH.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_OVH.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'ionos-objectstorage') {
|
||||
backupConfig.region = REGIONS_IONOS.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_IONOS.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'vultr-objectstorage') {
|
||||
backupConfig.region = REGIONS_VULTR.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_VULTR.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'contabo-objectstorage') {
|
||||
backupConfig.region = REGIONS_CONTABO.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
||||
config.region = REGIONS_CONTABO.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
config.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
||||
} else if (provider.value === 'upcloud-objectstorage') {
|
||||
const m = /^.*\.(.*)\.upcloudobjects.com$/.exec(providerConfig.value.endpoint);
|
||||
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'digitalocean-spaces') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
config.region = 'us-east-1';
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.noHardlinks = !providerConfig.value.useHardlinks;
|
||||
config.mountOptions = {};
|
||||
|
||||
if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs') {
|
||||
config.mountOptions.host = providerConfig.value.mountOptionHost;
|
||||
config.mountOptions.remoteDir = providerConfig.value.mountOptionRemoteDir;
|
||||
|
||||
if (provider.value === 'cifs') {
|
||||
config.mountOptions.username = providerConfig.value.mountOptionUsername;
|
||||
config.mountOptions.password = providerConfig.value.mountOptionPassword;
|
||||
config.mountOptions.seal = !!providerConfig.value.mountOptionSeal;
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
} else if (provider.value === 'sshfs') {
|
||||
config.mountOptions.user = providerConfig.value.mountOptionUser;
|
||||
config.mountOptions.port = parseInt(providerConfig.value.mountOptionPort);
|
||||
config.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
|
||||
config.preserveAttributes = true;
|
||||
}
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
config.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
|
||||
config.preserveAttributes = true;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
config.mountPoint = providerConfig.value.mountPoint;
|
||||
config.chown = !!providerConfig.value.chown;
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
}
|
||||
} else if (provider.value === 'gcs') {
|
||||
backupConfig.bucket = providerConfig.value.bucket;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.projectId = providerConfig.value.projectId;
|
||||
backupConfig.credentials = providerConfig.value.credentials;
|
||||
} else if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs' || provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
backupConfig.mountOptions = providerConfig.value.mountOptions;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.mountPoint = providerConfig.value.mountPoint;
|
||||
} else if (provider.value === 'filesystem') {
|
||||
const parts = remotePath.value.split('/');
|
||||
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
|
||||
backupConfig.backupDir = parts.join('/'); // this is dirname()
|
||||
config.backupDir = parts.join('/'); // this is dirname()
|
||||
} else if (provider.value === 'gcs') {
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.projectId = providerConfig.value.projectId;
|
||||
config.credentials = providerConfig.value.credentials;
|
||||
}
|
||||
|
||||
const data = {
|
||||
format: format.value,
|
||||
provider: provider.value,
|
||||
config: backupConfig,
|
||||
config: config,
|
||||
remotePath: backupPath
|
||||
};
|
||||
|
||||
@@ -176,12 +198,27 @@ function onBackupConfigChanged(event) {
|
||||
|
||||
provider.value = data.provider;
|
||||
remotePath.value = data.remotePath;
|
||||
providerConfig.value = data.config;
|
||||
format.value = data.format;
|
||||
encrypted.value = !!data.encrypted;
|
||||
encryptionPasswordHint.value = data.encryptionPasswordHint || '';
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = data.encryptedFilenames;
|
||||
|
||||
// translate for BackupProviderForm flattened object
|
||||
providerConfig.value = {};
|
||||
providerConfig.value.useHardlinks = !data.config.noHardlinks;
|
||||
providerConfig.value.prefix = data.config.prefix;
|
||||
providerConfig.value.chown = !!data.config.chown;
|
||||
providerConfig.value.preserveAttributes = data.config.preserveAttributes;
|
||||
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
|
||||
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
|
||||
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
|
||||
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
|
||||
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
|
||||
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
|
||||
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
|
||||
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
|
||||
providerConfig.value.mountOptionPrivateKey = '';
|
||||
};
|
||||
|
||||
reader.readAsText(event.target.files[0]);
|
||||
@@ -224,7 +261,10 @@ defineExpose({
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<div>{{ $t('app.importBackupDialog.description') }}</div>
|
||||
<div class="text-danger">{{ $t('app.importBackupDialog.warning') }}</div>
|
||||
|
||||
<!-- ideally, we get can get rid of this and just display this error from the imported config -->
|
||||
<p class="text-danger">{{ $t('app.importBackupDialog.versionMustMatchInfo') }}</p>
|
||||
|
||||
<p>{{ $t('app.importBackupDialog.provideBackupInfo') }}
|
||||
<input type="file" ref="backupConfigFileInput" @change="onBackupConfigChanged" accept="application/json, text/json" style="display:none"/>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/ut
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const STEP = Object.freeze({
|
||||
@@ -18,9 +19,11 @@ const STEP = Object.freeze({
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
|
||||
// reactive
|
||||
const busy = ref(false);
|
||||
@@ -32,7 +35,6 @@ const dialog = useTemplateRef('dialogHandle');
|
||||
const locationInput = useTemplateRef('locationInput');
|
||||
const description = computed(() => marked.parse(manifest.value.description || ''));
|
||||
const domains = ref([]);
|
||||
const dashboardDomain = ref('');
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (!domain.value) return false;
|
||||
@@ -77,6 +79,8 @@ const udpPorts = ref({});
|
||||
const secondaryDomains = ref({});
|
||||
const upstreamUri = ref('');
|
||||
const needsOverwriteDns = ref(false);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
function onDomainChange() {
|
||||
const tmp = domains.value.find(d => d.domain === domain.value);
|
||||
@@ -167,10 +171,14 @@ function onClose() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
});
|
||||
|
||||
const screenshotsContainer = useTemplateRef('screenshotsContainer');
|
||||
@@ -212,7 +220,7 @@ defineExpose({
|
||||
domains.value = domainList;
|
||||
|
||||
// preselect with dashboard domain
|
||||
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).domain;
|
||||
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
|
||||
|
||||
tcpPorts.value = a.manifest.tcpPorts;
|
||||
udpPorts.value = a.manifest.udpPorts;
|
||||
@@ -231,7 +239,7 @@ defineExpose({
|
||||
for (const p in secondaryDomains.value) {
|
||||
const port = secondaryDomains.value[p];
|
||||
port.value = port.defaultValue;
|
||||
port.domain = domains.value[0].domain;
|
||||
port.domain = dashboardDomain.value;
|
||||
}
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
@@ -304,7 +312,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest" :users="users" :groups="groups" :installation="true"/>
|
||||
|
||||
<div class="bottom-button-bar">
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
|
||||
@@ -108,10 +108,12 @@ async function onSubmit() {
|
||||
|
||||
async function onRemove(appPassword) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('profile.removeAppPassword.title', { name: appPassword.name }),
|
||||
title: t('profile.removeAppPassword.title'),
|
||||
message: t('profile.removeAppPassword.description', { name: appPassword.name }),
|
||||
confirmLabel: t('main.action.remove'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -161,9 +163,10 @@ onMounted(async () => {
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-label="addedPassword ? '' : $t('profile.createAppPassword.generatePassword')"
|
||||
:confirm-active="addedPassword || isValid"
|
||||
:confirm-label="addedPassword ? '' : $t('main.action.add')"
|
||||
confirm-style="primary"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
@close="onReset()"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// for restore from archive or clone !
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef, computed, inject } from 'vue';
|
||||
import { InputGroup, FormGroup, TextInput, SingleSelect, Dialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import PortBindings from '../components/PortBindings.vue';
|
||||
@@ -14,6 +14,7 @@ const appsModel = AppsModel.create();
|
||||
const archivesModel = ArchivesModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const appId = ref(null);
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const restoreArchive = ref({});
|
||||
@@ -119,7 +120,7 @@ defineExpose({
|
||||
|
||||
const app = archive.appConfig || {
|
||||
subdomain: '',
|
||||
domain: domains.value[0].domain,
|
||||
domain: dashboardDomain.value,
|
||||
secondaryDomains: [],
|
||||
portBindings: {}
|
||||
}; // pre-8.2 backups do not have appConfig
|
||||
@@ -129,7 +130,7 @@ defineExpose({
|
||||
|
||||
restoreLocation.value = app.subdomain;
|
||||
const d = domains.value.find(function (d) { return app.domain === d.domain; });
|
||||
restoreDomain.value = d ? d.domain : domains.value[0].domain; // try to pre-select the app's domain
|
||||
restoreDomain.value = d ? d.domain : dashboardDomain.value; // try to pre-select the app's domain
|
||||
restoreSecondaryDomains.value = {};
|
||||
needsOverwrite.value = false;
|
||||
restoreArchive.value = archive;
|
||||
@@ -190,7 +191,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="appId ? $t('app.cloneDialog.title', { app: fqdn }) : $t('backups.restoreArchiveDialog.title')"
|
||||
:title="appId ? $t('app.cloneDialog.title') : $t('backups.restoreArchiveDialog.title')"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
|
||||
@@ -98,8 +98,9 @@ async function onRemove() {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: `Really remove applink?`,
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -145,7 +146,7 @@ defineExpose({
|
||||
alternate-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-label="$t('main.action.add')"
|
||||
:confirm-active="isValid"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
@@ -172,7 +173,7 @@ defineExpose({
|
||||
|
||||
<div>
|
||||
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" size="512" display-height="80px" style="width: 80px"/>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" :size="512" display-height="80px" style="width: 80px"/>
|
||||
</div>
|
||||
|
||||
<FormGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, onMounted, watch, watchEffect } from 'vue';
|
||||
import { Button, InputGroup, SingleSelect, FormGroup, TextInput, Checkbox, PasswordInput, NumberInput } from '@cloudron/pankow';
|
||||
import { BACKUP_FORMATS, STORAGE_PROVIDERS, REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_EXOSCALE, REGIONS_DIGITALOCEAN, REGIONS_HETZNER, REGIONS_WASABI, REGIONS_S3 } from '../constants.js';
|
||||
import ProvisionModel from '../models/ProvisionModel.js';
|
||||
@@ -100,6 +100,11 @@ watch(provider, (newProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (!providerConfig.value.mountOptionPrivateKey) return;
|
||||
providerConfig.value.mountOptionPrivateKey = providerConfig.value.mountOptionPrivateKey.replaceAll('\\n', '\n');
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await getBlockDevices();
|
||||
});
|
||||
@@ -113,7 +118,7 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('backups.configureBackupStorage.provider') }} <sup><a href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" />
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- mountpoint -->
|
||||
@@ -125,13 +130,13 @@ onMounted(async () => {
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }} ({{ provider }})</label>
|
||||
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }}</label>
|
||||
<TextInput id="mountOptionHostInput" v-model="providerConfig.mountOptionHost" placeholder="Server IP or hostname" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }} ({{ provider }})</label>
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }}</label>
|
||||
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
|
||||
</FormGroup>
|
||||
|
||||
@@ -140,13 +145,13 @@ onMounted(async () => {
|
||||
|
||||
<!-- CIFS -->
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
|
||||
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }}</label>
|
||||
<TextInput id="mountOptionUsernameInput" v-model="providerConfig.mountOptionUsername" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS -->
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
|
||||
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }}</label>
|
||||
<PasswordInput id="mountOptionPasswordInput" v-model="providerConfig.mountOptionPassword" required />
|
||||
</FormGroup>
|
||||
|
||||
@@ -178,7 +183,7 @@ onMounted(async () => {
|
||||
<!-- SSHFS -->
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
|
||||
<textarea id="mountOptionPrivateKeyInput" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
|
||||
<textarea id="mountOptionPrivateKeyInput" rows="7" style="white-space: nowrap;" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Filesystem -->
|
||||
|
||||
@@ -29,7 +29,7 @@ const formError = ref({});
|
||||
const busy = ref(false);
|
||||
const enableForUpdates = ref(false);
|
||||
const provider = ref('');
|
||||
const includeExclude = ref('everything'); // or exclude, include
|
||||
const includeExclude = ref(''); // or exclude, include
|
||||
const contentOptions = ref([]);
|
||||
const contentInclude = ref([]);
|
||||
const contentExclude = ref([]);
|
||||
@@ -227,6 +227,12 @@ function onCancel() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
const isValid = ref(false);
|
||||
|
||||
function checkValidity() {
|
||||
isValid.value = form.value.checkValidity();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
step.value = 'storage';
|
||||
@@ -247,7 +253,7 @@ defineExpose({
|
||||
encryptionPasswordHint.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
limits.value = {};
|
||||
includeExclude.value = 'everything';
|
||||
includeExclude.value = '';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
@@ -282,6 +288,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
// checkValidity();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -291,9 +299,9 @@ defineExpose({
|
||||
<Dialog ref="dialog" :title="$t('backups.site.addDialog.title')">
|
||||
<div>
|
||||
<div v-if="step === 'storage'">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @change="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
<input style="display: none;" type="submit" :disabled="!isValid"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
|
||||
@@ -306,10 +314,10 @@ defineExpose({
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div description>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div>
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')" required/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')" required/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')" required/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
@@ -370,7 +378,7 @@ defineExpose({
|
||||
|
||||
<div style="display: flex; gap: 6px; align-items: end;">
|
||||
<Button secondary v-if="!busy" :disabled="busy" @click="onCancel()">{{ $t('main.dialog.cancel') }}</Button>
|
||||
<Button primary :disabled="busy" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
<Button primary :disabled="busy || !isValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike, regionName } from '../utils.js';
|
||||
@@ -38,6 +38,11 @@ const useHardlinks = ref(false);
|
||||
const chown = ref(false);
|
||||
const preserveAttributes = ref(false);
|
||||
|
||||
watch(mountOptionsPrivateKey, () => {
|
||||
if (!mountOptionsPrivateKey.value) return;
|
||||
mountOptionsPrivateKey.value = mountOptionsPrivateKey.value.replaceAll('\\n', '\n');
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
@@ -249,13 +254,13 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
|
||||
<p class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</p>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</div>
|
||||
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
|
||||
<datalist id="uploadPartSizeTicks">
|
||||
<option :value="1024*1024*10"></option>
|
||||
@@ -269,19 +274,19 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync'">
|
||||
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
|
||||
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
|
||||
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
|
||||
<span v-show="provider === 'digitalocean-spaces'">{{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }}</span>
|
||||
</div>
|
||||
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
|
||||
|
||||
+12
-10
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Checkbox, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
|
||||
@@ -13,7 +13,7 @@ const id = ref('');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const scheduleEnabled = ref(false);
|
||||
const scheduleType = ref('');
|
||||
const days = ref([]);
|
||||
const hours = ref([]);
|
||||
const configureRetention = ref(''); // this is 'name' and not 'id' of backupRetentions because SingleSelect needs strings
|
||||
@@ -27,7 +27,7 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
let schedule;
|
||||
if (scheduleEnabled.value) {
|
||||
if (scheduleType.value === 'pattern') {
|
||||
let daysPattern;
|
||||
if (days.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = days.value;
|
||||
@@ -73,9 +73,9 @@ defineExpose({
|
||||
configureRetention.value = selectedRetention ? selectedRetention.name : BackupSitesModel.backupRetentions[0].name;
|
||||
|
||||
if (site.schedule === 'never') {
|
||||
scheduleEnabled.value = false;
|
||||
scheduleType.value = 'never';
|
||||
} else {
|
||||
scheduleEnabled.value = true;
|
||||
scheduleType.value = 'pattern';
|
||||
|
||||
const tmp = site.schedule.split(' ');
|
||||
const tmpHours = tmp[2].split(',');
|
||||
@@ -111,12 +111,14 @@ defineExpose({
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule') }}</label>
|
||||
<div v-html="$t('backups.configureBackupSchedule.scheduleDescription')"></div>
|
||||
<div description v-html="$t('backups.configureBackupSchedule.scheduleDescription')"></div>
|
||||
|
||||
<Checkbox :label="$t('main.statusEnabled')" v-model="scheduleEnabled" />
|
||||
<div v-if="scheduleEnabled" style="display: flex; gap: 10px; margin-left: 10px">
|
||||
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" :disabled="!scheduleEnabled" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect :disabled="!scheduleEnabled" v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<Radiobutton v-model="scheduleType" value="never" :label="$t('backups.configureBackupSchedule.disable')"/>
|
||||
<Radiobutton v-model="scheduleType" value="pattern" :label="$t('backups.configureBackupSchedule.enable')"/>
|
||||
<div v-if="scheduleType === 'pattern'" style="display: flex; align-items: center; gap: 10px; margin: 10px">
|
||||
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div class="text-small text-danger" v-show="scheduleType === 'pattern' && !(hours.length !== 0 && days.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -77,7 +77,7 @@ onMounted(async () => {
|
||||
<div style="display: flex; justify-content: space-around; margin-bottom: 20px;">
|
||||
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
|
||||
<label>{{ $t('branding.logo') }}</label>
|
||||
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding"/>
|
||||
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding" fallback-src="/api/v1/cloudron/avatar"/>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
|
||||
|
||||
@@ -130,7 +130,7 @@ defineExpose({ updateDomains: selectCurrentDomain });
|
||||
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
<div v-if="lastTask.active" style="padding: 0 10px">
|
||||
<div v-if="lastTask.active">
|
||||
<ProgressBar :value="lastTask.percent" :busy="true" />
|
||||
<div>{{ lastTask.message }}</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Section :title="$t('system.diskUsage.title')">
|
||||
<div class="filesystems-grid">
|
||||
<div>
|
||||
<DiskUsageItem v-for="filesystem in filesystems" :key="filesystem.filesystem" :filesystem="filesystem" />
|
||||
</div>
|
||||
</Section>
|
||||
@@ -30,21 +30,6 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.filesystems-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: 300ms;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.filesystems-grid {
|
||||
grid-template-columns: 1fr; /* Single column on small screens */
|
||||
}
|
||||
}
|
||||
|
||||
.usage-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@@ -3,35 +3,15 @@
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { Button, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import VolumesModel from '../models/VolumesModel.js';
|
||||
import { getColor } from '../utils.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const volumesModel = VolumesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const props = defineProps({
|
||||
filesystem: Object
|
||||
});
|
||||
|
||||
function hue(numOfSteps, step) {
|
||||
const deg = 360/numOfSteps;
|
||||
return `hsl(${deg*step} 70% 50%)`;
|
||||
}
|
||||
|
||||
let colorIndex = 0;
|
||||
let colors = [];
|
||||
function resetColors(n) {
|
||||
colorIndex = 7;
|
||||
colors = [];
|
||||
for (let i = 0; i < n; i++) colors.push(hue(n, i));
|
||||
}
|
||||
|
||||
function getNextColor() {
|
||||
return colors[colorIndex++];
|
||||
}
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const percent = ref(0);
|
||||
const contents = ref([]);
|
||||
@@ -41,19 +21,7 @@ const highlight = ref(null);
|
||||
let eventSource = null;
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const appsById = {};
|
||||
result.forEach(a => { appsById[a.id] = a; });
|
||||
|
||||
[error, result] = await volumesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const volumesById = {};
|
||||
result.forEach(v => { volumesById[v.id] = v; });
|
||||
|
||||
[error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
|
||||
contents.value = [];
|
||||
@@ -66,20 +34,7 @@ async function refresh() {
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
|
||||
// we first 8 colors are reserved for known system contents
|
||||
resetColors(contents.value.length + 8);
|
||||
contents.value.forEach(content => {
|
||||
// assign fixed colors for known entries
|
||||
if (content.id === 'platformdata') content.color = colors[0];
|
||||
else if (content.id === 'boxdata') content.color = colors[1];
|
||||
else if (content.id === 'maildata') content.color = colors[2];
|
||||
else if (content.id === 'cloudron-backup-default') content.color = colors[3];
|
||||
else if (content.id === 'docker') content.color = colors[4];
|
||||
else if (content.id === 'docker-volumes') content.color = colors[5];
|
||||
else if (content.id === '/apps.swap') content.color = colors[6];
|
||||
else if (content.id === 'os') content.color = colors[7];
|
||||
else content.color = getNextColor();
|
||||
});
|
||||
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
|
||||
contents.value.sort((a, b) => b.usage - a.usage);
|
||||
|
||||
eventSource.close();
|
||||
@@ -89,16 +44,8 @@ async function refresh() {
|
||||
if (payload.speed) {
|
||||
speed.value = payload.speed;
|
||||
} else if (payload.content) {
|
||||
if (payload.content.type === 'app') {
|
||||
payload.content.app = appsById[payload.content.id];
|
||||
if (!payload.content.app) payload.content.uninstalled = true;
|
||||
else payload.content.label = payload.content.app.label || payload.content.app.fqdn;
|
||||
} else if (payload.content.type === 'volume') {
|
||||
payload.content.volume = volumesById[payload.content.id];
|
||||
payload.content.label = payload.content.volume ? `Volume ${payload.content.volume.name}` : 'Removed volume';
|
||||
} else {
|
||||
payload.content.label = payload.content.id;
|
||||
}
|
||||
// this can happen if more than one backup sites for filesystem share the folder, so avoid negativ values here
|
||||
if (payload.content.usage < 0) payload.content.usage = 0;
|
||||
contents.value.push(payload.content);
|
||||
} else {
|
||||
console.error('Unkown data', payload);
|
||||
@@ -139,16 +86,20 @@ onUnmounted(() => {
|
||||
<div v-if="isExpanded" @mouseout="highlight = null">
|
||||
<ProgressBar v-if="percent < 100" mode="indeterminate" :show-label="false"/>
|
||||
<div v-else class="disk-size" style="overflow: visible;">
|
||||
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.id" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
|
||||
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.name" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="percent < 100" style="text-align: center; margin-top: 10px;">Calculating speed and disk usage ... {{ parseInt(percent) }}%</div>
|
||||
<div v-else>
|
||||
<table style="width: 100%">
|
||||
<table style="width: 100%;table-layout: fixed">
|
||||
<tr v-for="content in contents" :key="content.id" @mouseover="highlight = content.id" :class="{ highlight: highlight === content.id }">
|
||||
<td style="width: 20px"><div class="content-color-indicator" :style="{ backgroundColor: content.color }"></div></td>
|
||||
<td>{{ content.label }}</td>
|
||||
<td style="text-align: right">{{ prettyDecimalSize(content.usage) }}</td>
|
||||
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
<a v-if="content.type === 'app'" :href="`/#/app/${content.id}/info`">{{ content.name }}</a>
|
||||
<a v-else-if="content.type === 'volume'" href="/#/volumes">{{ content.name }} (Volume)</a>
|
||||
<span v-else>{{ content.name }}</span>
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">{{ prettyDecimalSize(content.usage) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -173,6 +124,7 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background-color: var(--card-background);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disk-item:focus,
|
||||
|
||||
@@ -62,11 +62,12 @@ function onEditOrAdd(registry = null) {
|
||||
|
||||
async function onRemove(registry) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
title: t('dockerRegistries.removeDialog.title', { serverAddress: registry.serverAddress}),
|
||||
message: t('dockerRegistres.removeDialog.description'),
|
||||
title: t('dockerRegistries.removeDialog.title'),
|
||||
message: t('dockerRegistres.removeDialog.description', { serverAddress: registry.serverAddress }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.delete'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
@@ -18,7 +18,6 @@ const providers = [
|
||||
{ name: 'Google Cloud', value: 'google-cloud' },
|
||||
{ name: 'Linode', value: 'linode' },
|
||||
{ name: 'Quay', value: 'quay' },
|
||||
{ name: 'Treescale', value: 'treescale' },
|
||||
{ name: t('settings.registryConfig.providerOther') || 'Other', value: 'other' },
|
||||
];
|
||||
|
||||
@@ -83,8 +82,8 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('dockerRegistries.dialog.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:title="registry ? $t('dockerRegistries.dialog.editTitle') : $t('dockerRegistries.dialog.addTitle')"
|
||||
:confirm-label="registry ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Checkbox, Button } from '@cloudron/pankow';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Button } from '@cloudron/pankow';
|
||||
import { getTextFromFile } from '../utils.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import DomainProviderForm from './DomainProviderForm.vue';
|
||||
@@ -131,10 +131,10 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="editing ? $t('domains.domainDialog.editTitle', { domain: domain }) : $t('domains.domainDialog.addTitle')"
|
||||
:title="editing ? $t('domains.domainDialog.editTitle') : $t('domains.domainDialog.addTitle')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-label="editing ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@@ -156,14 +156,14 @@ defineExpose({
|
||||
<div v-show="showAdvanced">
|
||||
<div v-if="tlsProvider === 'fallback'">
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
|
||||
<p v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></p>
|
||||
<div description v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="tlsProvider === 'fallback'">
|
||||
<input type="file" ref="certificateFileInput" style="display: none" @change="onCertificateFileChange"/>
|
||||
<input type="file" ref="keyFileInput" style="display: none" @change="onKeyFileChange"/>
|
||||
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px">
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-top: 15px">
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertCertificatePlaceholder') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="certificateFileName" @click="certificateFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
|
||||
|
||||
@@ -133,6 +133,10 @@ function onGcdnsFileInputChange(event) {
|
||||
<SingleSelect v-model="provider" @select="onProviderChange" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
<!-- Route53 -->
|
||||
<FormGroup v-if="provider === 'route53'">
|
||||
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
|
||||
@@ -259,7 +263,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
|
||||
<!-- Hetzner -->
|
||||
<FormGroup v-if="provider === 'hetzner'">
|
||||
<FormGroup v-if="provider === 'hetzner' || provider === 'hetznercloud'">
|
||||
<label for="hetznerTokenInput">{{ $t('domains.domainDialog.hetznerToken') }}</label>
|
||||
<MaskedInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
@@ -320,9 +324,5 @@ function onGcdnsFileInputChange(event) {
|
||||
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -60,7 +60,7 @@ function cancel() {
|
||||
<Button tool plain secondary @click="cancel" :disabled="saving">{{ $t('main.dialog.cancel') }}</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="markdown" v-html="marked.parse(value)"></div>
|
||||
<div v-if="markdown" v-html="marked.parseInline(value)"></div>
|
||||
<div v-else>{{ value }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -113,15 +113,16 @@ onMounted(async () => {
|
||||
@confirm="onBlocklistSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p class="small">{{ $t('network.firewall.configure.description') }}</p>
|
||||
|
||||
<div class="small">{{ $t('network.firewall.configure.description') }}</div>
|
||||
<br/>
|
||||
<form novalidate @submit.prevent="onBlocklistSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBlocklistBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBlocklistBusy || !isBlocklistValid"/>
|
||||
<FormGroup>
|
||||
<label for="blocklistInput">{{ $t('network.firewall.blockedIpRanges') }}</label>
|
||||
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
|
||||
<div class="has-error" v-show="editBlocklistError">{{ editBlocklistError }}</div>
|
||||
<textarea id="blocklistInput" v-model="editBlocklist" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
|
||||
<textarea id="blocklistInput" v-model="editBlocklist" rows="4"></textarea>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -138,15 +139,16 @@ onMounted(async () => {
|
||||
@confirm="onTrustedIpsSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p class="small">{{ $t('network.trustedIps.description') }}</p>
|
||||
|
||||
<div class="small">{{ $t('network.trustedIps.description') }}</div>
|
||||
<br/>
|
||||
<form novalidate @submit.prevent="onTrustedIpsSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editTrustedIpsBusy">
|
||||
<input style="display: none;" type="submit" :disabled="editTrustedIpsBusy || !isTrustedIpsValid"/>
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('network.trustedIpRanges') }}</label>
|
||||
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
|
||||
<div class="has-error" v-show="editTrustedIpsError">{{ editTrustedIpsError }}</div>
|
||||
<textarea v-model="editTrustedIps" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
|
||||
<textarea v-model="editTrustedIps" rows="4"></textarea>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -64,7 +64,7 @@ const uploadMenuModel = [{
|
||||
action: onUploadFile,
|
||||
}, {
|
||||
icon: 'fa-regular fa-folder-open',
|
||||
label: t('filemanager.toolbar.newFolder'),
|
||||
label: t('filemanager.toolbar.uploadFolder'),
|
||||
action: onUploadFolder,
|
||||
}];
|
||||
|
||||
@@ -443,7 +443,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
appLink.value = `https://${result.body.fqdn}`;
|
||||
title.value = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
|
||||
title.value = `${result.body.label || result.body.fqdn} ` + (result.body.manifest ? `(${result.body.manifest.title})` : '');
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
|
||||
@@ -200,8 +200,10 @@ function pruneGraphData(dataset, options) {
|
||||
}
|
||||
|
||||
function advance() {
|
||||
graph.options.scales.x.min += LIVE_REFRESH_INTERVAL_MSECS;
|
||||
graph.options.scales.x.max += LIVE_REFRESH_INTERVAL_MSECS;
|
||||
// advance is called in a timer and when the browser tab is in the background , it is unreliable. Use absolute time to set the scale
|
||||
const now = Date.now();
|
||||
graph.options.scales.x.min = now - 5*60*1000;
|
||||
graph.options.scales.x.max = now;
|
||||
graph.update('none');
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="group ? $t('users.editGroupDialog.title', { name: group.name }) : $t('users.addGroupDialog.title')"
|
||||
:title="group ? $t('users.editGroupDialog.title') : $t('users.addGroupDialog.title')"
|
||||
:confirm-label="group ? $t('main.dialog.save') : $t('users.group.addGroupAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== ''"
|
||||
@@ -108,7 +108,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="appsInput">Access to Apps</label>
|
||||
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
|
||||
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -33,6 +33,9 @@ const notificationsAllBusy = ref(false);
|
||||
|
||||
function onOpenNotifications(popover, event, elem) {
|
||||
popover.open(event, elem);
|
||||
|
||||
// close after 2 seconds if there is nothing to show
|
||||
if (notifications.value.length === 0) setTimeout(popover.close, 2000);
|
||||
}
|
||||
|
||||
async function onMarkNotificationRead(notification) {
|
||||
@@ -41,6 +44,9 @@ async function onMarkNotificationRead(notification) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refresh();
|
||||
|
||||
// close after 2 seconds if there is nothing to show
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function onMarkAllNotificationRead() {
|
||||
@@ -55,6 +61,8 @@ async function onMarkAllNotificationRead() {
|
||||
await refresh();
|
||||
|
||||
notificationsAllBusy.value = false;
|
||||
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
|
||||
@@ -10,8 +10,8 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'editable', required: true },
|
||||
src: { type: String, required: true },
|
||||
fallbackSrc: { type: String, required: true },
|
||||
size: { type: String, required: true },
|
||||
maxSize: { type: String, required: false },
|
||||
size: { type: Number, required: false, default: 512 },
|
||||
maxSize: { type: Number, required: false, default: 0 },
|
||||
displayHeight: { type: String, required: false },
|
||||
displayWidth: { type: String, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
@@ -109,22 +109,19 @@ function onChanged(event) {
|
||||
fr.onload = function () {
|
||||
const image = new Image();
|
||||
image.onload = function () {
|
||||
const size = props.size ? parseInt(props.size) : 512;
|
||||
const maxSize = props.maxSize ? parseInt(props.maxSize) : 0;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
if (maxSize) {
|
||||
if (image.naturalWidth > maxSize) {
|
||||
canvas.width = maxSize;
|
||||
canvas.height = (image.naturalHeight / image.naturalWidth) * maxSize;
|
||||
if (props.maxSize) {
|
||||
if (image.naturalWidth > props.maxSize) {
|
||||
canvas.width = props.maxSize;
|
||||
canvas.height = (image.naturalHeight / image.naturalWidth) * props.maxSize;
|
||||
} else {
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
}
|
||||
} else {
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
canvas.width = props.size;
|
||||
canvas.height = props.size;
|
||||
}
|
||||
|
||||
const imageDimensionRatio = image.width / image.height;
|
||||
@@ -155,8 +152,7 @@ function onChanged(event) {
|
||||
internalSrc.value = canvas.toDataURL('image/png');
|
||||
isChanged.value = true;
|
||||
|
||||
console.log('internalSrc is now some data url');
|
||||
emit('changed', file);
|
||||
emit('changed', dataURLtoFile(internalSrc.value, 'image.png'));
|
||||
};
|
||||
|
||||
image.src = fr.result;
|
||||
@@ -177,7 +173,6 @@ function onError() {
|
||||
|
||||
<div ref="image" :disabled="disabled || null" class="image-picker" @click="!disabled && onEdit()">
|
||||
<img :src="internalSrc || src" @error="onError" class="image-picker-image" :style="{ height: displayHeight || null, width: displayWidth || null }">
|
||||
|
||||
<!-- Editable mode -->
|
||||
<template v-if="mode === 'editable'">
|
||||
<div v-if="isChanged" class="image-picker-actions" style="visibility: visible;">
|
||||
|
||||
@@ -56,16 +56,17 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('users.setGhostDialog.title', { username: user.username })"
|
||||
:title="$t('users.setGhostDialog.title')"
|
||||
:reject-label="success ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="success ? '' : $t('users.setGhostDialog.setPassword')"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p>{{ $t('users.setGhostDialog.context', { username: user.username }) }}</p>
|
||||
<p>{{ $t('users.setGhostDialog.description') }}</p>
|
||||
<p class="text-danger" v-show="formError">{{ formError }}</p>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="none">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<FormGroup>
|
||||
<label for="passwordInput">{{ $t('users.setGhostDialog.password') }}</label>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import NetworkModel from '../models/NetworkModel.js';
|
||||
|
||||
@@ -113,26 +113,26 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
|
||||
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
|
||||
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<p v-show="editProvider === 'generic'">
|
||||
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
|
||||
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fixed -->
|
||||
<FormGroup v-show="editProvider === 'fixed'">
|
||||
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
|
||||
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
|
||||
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
|
||||
<div class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Network Interface -->
|
||||
<FormGroup v-show="editProvider === 'network-interface'">
|
||||
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
|
||||
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
|
||||
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet -br addr <ClipboardAction plain value="ip -f inet -br addr" /></div>
|
||||
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
|
||||
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
|
||||
<div class="has-error" v-show="editError.ifname">{{ editError.ifname }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -140,10 +140,6 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('network.ip.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('network.ip.description') }}</div>
|
||||
<br/>
|
||||
|
||||
@@ -159,6 +155,8 @@ onMounted(async () => {
|
||||
<div class="info-label">{{ $t('network.ip.interface') }}</div>
|
||||
<div class="info-value">{{ interfaceName }}</div>
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import NetworkModel from '../models/NetworkModel.js';
|
||||
|
||||
@@ -116,7 +116,7 @@ onMounted(async () => {
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<div v-show="editProvider === 'generic'">
|
||||
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
|
||||
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +130,9 @@ onMounted(async () => {
|
||||
<!-- Network Interface -->
|
||||
<FormGroup v-show="editProvider === 'network-interface'">
|
||||
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
|
||||
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet6 -br addr <ClipboardAction plain value="ip -f inet6 -br addr" /></div>
|
||||
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
|
||||
<div class="error-label" v-show="editError.ifname">{{ editError.ifname }}</div>
|
||||
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet6 -br addr</code></p>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -140,10 +140,6 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('network.ipv6.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('network.ipv6.description') }}</div>
|
||||
<br/>
|
||||
|
||||
@@ -159,6 +155,8 @@ onMounted(async () => {
|
||||
<div class="info-label">{{ $t('network.ip.interface') }}</div>
|
||||
<div class="info-value">{{ interfaceName }}</div>
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,7 +57,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
ldapUrl.value = `ldaps://${result.adminFqdn}:636`;
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain) || domains[0];
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain);
|
||||
|
||||
[error, result] = await userDirectoryModel.getExposedLdapConfig();
|
||||
if (error) return console.error(error);
|
||||
|
||||
@@ -7,7 +7,9 @@ import MailModel from '../models/MailModel.js';
|
||||
import { RELAY_PROVIDERS } from '../constants.js';
|
||||
import { prettyRelayProviderName } from '../utils';
|
||||
|
||||
const props = defineProps(['domain']);
|
||||
const props = defineProps({
|
||||
domain: { type: String, required: true }
|
||||
});
|
||||
|
||||
const mailModel = MailModel.create();
|
||||
|
||||
@@ -21,6 +23,7 @@ const dialog = useTemplateRef('dialog');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const adminDomain = ref('');
|
||||
const currentProvider = ref('cloudron-smtp');
|
||||
const provider = ref('cloudron-smtp');
|
||||
const host = ref('');
|
||||
const port = ref(1);
|
||||
@@ -130,6 +133,8 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
currentProvider.value = provider.value;
|
||||
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
@@ -140,6 +145,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
provider.value = result.relay.provider;
|
||||
currentProvider.value = result.relay.provider;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -207,7 +213,7 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.outbound.title') }} <sup><a href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>
|
||||
<b>{{ prettyRelayProviderName(provider) }}</b> - <span v-html="$t('email.outbound.description')"></span>
|
||||
<b>{{ prettyRelayProviderName(currentProvider) }}</b> - <span v-html="$t('email.outbound.description')"></span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center;">
|
||||
|
||||
@@ -109,20 +109,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<SettingsItem wrap>
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<div v-html="$t('emails.changeDomainDialog.description')"></div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
<form @submit.prevent="onSubmit()">
|
||||
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
|
||||
<form @submit.prevent="onSubmit()" style="display: flex; align-items: center; width: 100%; justify-content: end;" autocomplete="off">
|
||||
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
|
||||
|
||||
<InputGroup>
|
||||
<TextInput v-model="subdomain" :disabled="busy"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
|
||||
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('main.dialog.save') }}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
<InputGroup style="overflow: hidden;">
|
||||
<TextInput v-model="subdomain" :disabled="busy" style="width: 120px"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
|
||||
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('emails.changeDomainDialog.setAction') }}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef, computed, inject } from 'vue';
|
||||
import { Dialog, Button, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import MailboxesModel from '../models/MailboxesModel.js';
|
||||
@@ -10,6 +10,7 @@ const props = defineProps([ 'apps', 'users', 'groups', 'domains' ]);
|
||||
|
||||
const mailboxesModel = MailboxesModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
@@ -34,7 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
name: '',
|
||||
domain: '@' + props.domains[0].domain,
|
||||
domain: dashboardDomain.value,
|
||||
label: '@' + dashboardDomain.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,24 +93,35 @@ defineExpose({
|
||||
mailbox.value = m;
|
||||
|
||||
name.value = m ? m.name : '';
|
||||
domain.value = m ? m.domain : props.domains[0].domain;
|
||||
domain.value = m ? m.domain : dashboardDomain.value;
|
||||
ownerId.value = m ? m.ownerId : '';
|
||||
aliases.value = m ? m.aliases : [];
|
||||
active.value = m ? m.active : true;
|
||||
enablePop3.value = m ? m.enablePop3 : false;
|
||||
storageQuotaEnabled.value = m && m.storageQuota ? true : false;
|
||||
storageQuota.value = m ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [{ separator: true, label: 'Users' }]
|
||||
.concat(props.users)
|
||||
.concat([{ separator: true, label: 'Groups' }])
|
||||
.concat(props.groups)
|
||||
.concat([{ separator: true, label: 'Apps' }])
|
||||
.concat(props.apps);
|
||||
usersAndGroupsAndApps.value = [];
|
||||
|
||||
if (props.users.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Users' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.users);
|
||||
|
||||
if (props.groups.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Groups' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.groups);
|
||||
|
||||
if (props.apps.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Apps' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.apps);
|
||||
|
||||
// unify on .name for multiselect
|
||||
usersAndGroupsAndApps.value.forEach(u => {
|
||||
u.icon = u.name ? 'fa-solid fa-users' : (u.username ? 'fa-solid fa-user' : 'fa-solid fa-cube') ;
|
||||
u.name = u.name || u.username || u.label || u.fqdn;
|
||||
usersAndGroupsAndApps.value.forEach(item => {
|
||||
if (item.appIds) {
|
||||
item.icon = 'fa-solid fa-users';
|
||||
} else if (item.username) {
|
||||
item.icon = 'fa-solid fa-user';
|
||||
item.name = item.username;
|
||||
} else {
|
||||
item.icon = 'fa-solid fa-cube';
|
||||
item.name = item.label || item.fqdn;
|
||||
}
|
||||
});
|
||||
|
||||
domainList.value = props.domains.map(d => {
|
||||
@@ -127,7 +140,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailbox ? $t('email.editMailboxDialog.title', { name: mailbox.name, domain: mailbox.domain }) : $t('email.addMailboxDialog.title')"
|
||||
:title="mailbox ? $t('email.editMailboxDialog.title') : $t('email.addMailboxDialog.title')"
|
||||
:confirm-label="$t(mailbox ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== ''"
|
||||
@@ -142,11 +155,11 @@ defineExpose({
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
|
||||
|
||||
<FormGroup v-if="!mailbox">
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailbox ? true : undefined"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox"/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="!domainHasIncomingEnabled">{{ $t('email.addMailboxDialog.incomingDisabledWarning') }}</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, inject } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import MailinglistsModel from '../models/MailinglistsModel.js';
|
||||
|
||||
@@ -19,6 +19,7 @@ const membersText = ref('');
|
||||
const membersOnly = ref(false);
|
||||
const active = ref(true);
|
||||
const domainList = ref([]);
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
@@ -63,7 +64,7 @@ defineExpose({
|
||||
mailinglist.value = m;
|
||||
|
||||
name.value = m ? m.name : '';
|
||||
domain.value = m ? m.domain : props.domains[0].domain;
|
||||
domain.value = m ? m.domain : dashboardDomain.value;
|
||||
membersText.value = m ? m.members.join('\n') : '';
|
||||
membersOnly.value = m ? m.membersOnly : false;
|
||||
active.value = m ? m.active : true;
|
||||
@@ -83,7 +84,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailinglist ? $t('email.editMailinglistDialog.title', { name: mailinglist.name, domain: mailinglist.domain }) : $t('email.addMailinglistDialog.title')"
|
||||
:title="mailinglist ? $t('email.editMailinglistDialog.title') : $t('email.addMailinglistDialog.title')"
|
||||
:confirm-label="$t(mailinglist ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== '' && membersText !== ''"
|
||||
@@ -99,11 +100,11 @@ defineExpose({
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
|
||||
|
||||
<FormGroup v-if="!mailinglist">
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailinglist ? true : undefined"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailinglist"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { FormGroup, MultiSelect } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
|
||||
defineProps(['hasFtp']);
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
defineProps(['hasFtp', 'users', 'groups']);
|
||||
|
||||
const accessRestriction = defineModel('acl');
|
||||
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@ const udpPorts = defineModel('udp');
|
||||
<div v-for="ports in [ tcpPorts, udpPorts ]" :key="ports">
|
||||
<FormGroup v-for="(port, key) in ports" :key="key" style="margin-top: 10px;">
|
||||
<Checkbox :label="port.title" v-model="port.enabled" />
|
||||
<small>{{ port.description + '. ' + (port.portCount >=1 ? (port.portCount + ' ports. ') : '') }}</small>
|
||||
<small>{{ port.description + (port.portCount > 1 ? ('. ' + port.portCount + ' ports. ') : '') }}</small>
|
||||
<small v-show="port.readOnly">{{ $t('appstore.installDialog.portReadOnly') }}</small>
|
||||
<small class="has-error" v-if="error.port === port.value">Port already taken {{ port }}</small>
|
||||
<NumberInput v-model="port.value" :disabled="!port.enabled" :min="1"/>
|
||||
@@ -24,3 +24,10 @@ const udpPorts = defineModel('udp');
|
||||
</FormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pankow-form-group small {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,10 @@ defineProps({
|
||||
type: String,
|
||||
default: `${API_ORIGIN}/api/v1/cloudron/avatar`,
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -20,11 +24,13 @@ defineProps({
|
||||
<div class="public-page-layout-root">
|
||||
<div class="public-page-layout-left pankow-no-mobile" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
|
||||
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
|
||||
<div class="cloudron-name">{{ cloudronName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="public-page-layout-right">
|
||||
<div class="public-page-layout-mobile-logo">
|
||||
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
|
||||
<div class="cloudron-name">{{ cloudronName }}</div>
|
||||
</div>
|
||||
<div class="public-page-layout-right-slot">
|
||||
<slot></slot>
|
||||
@@ -94,11 +100,18 @@ defineProps({
|
||||
}
|
||||
}
|
||||
|
||||
.public-page-layout-left img {
|
||||
margin-bottom: 20%;
|
||||
.public-page-layout-left .cloudron-avatar {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.public-page-layout-left .cloudron-name {
|
||||
font-family: var(--font-family--header);
|
||||
font-weight: 400;
|
||||
font-size: 1.75em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
@@ -141,11 +154,18 @@ defineProps({
|
||||
justify-content: start;
|
||||
flex-basis: unset;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.public-page-layout-right-slot {
|
||||
max-width: unset;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cloudron-avatar {
|
||||
border-radius: 10px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
|
||||
import { inject } from 'vue';
|
||||
import { inject, useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const mobileFilterBar = useTemplateRef('mobileFilterBar');
|
||||
|
||||
const isMobile = ref(false);
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -16,6 +20,19 @@ function onTitleBadge() {
|
||||
subscriptionRequiredDialog.value.open();
|
||||
}
|
||||
|
||||
function checkForMobile() {
|
||||
isMobile.value = window.innerWidth <= 576;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForMobile();
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,8 +45,9 @@ function onTitleBadge() {
|
||||
</div>
|
||||
<div class="section-header-title-badge" v-if="titleBadge" @click="onTitleBadge()">{{ titleBadge }}</div>
|
||||
</div>
|
||||
<div><slot name="header-buttons"></slot></div>
|
||||
<div><Teleport :disabled="!isMobile" :to="mobileFilterBar"><slot name="filter-bar"></slot></Teleport><slot name="header-buttons"></slot></div>
|
||||
</h2>
|
||||
<div class="section-mobile-filter-bar" v-show="isMobile && $slots['filter-bar']" ref="mobileFilterBar"></div>
|
||||
<hr class="section-divider"/>
|
||||
<div class="section-body">
|
||||
<slot></slot>
|
||||
@@ -102,4 +120,11 @@ function onTitleBadge() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-mobile-filter-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -104,13 +104,11 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicPageLayout :footerHtml="footer">
|
||||
<PublicPageLayout :footer-html="footer" :cloudron-name="cloudronName">
|
||||
<div>
|
||||
<div v-if="mode === MODE.SETUP">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<div>{{ $t('setupAccount.description') }}</div>
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<p style="margin-bottom: 8px">{{ $t('setupAccount.description') }}</p>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
@@ -151,24 +149,21 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.NO_USERNAME">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h3>{{ $t('setupAccount.noUsername.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.noUsername.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.INVALID_TOKEN">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h3 class="error-label">{{ $t('setupAccount.invalidToken.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.invalidToken.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.DONE">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h3>{{ $t('setupAccount.success.title') }}</h3>
|
||||
<Button :href="dashboardUrl">{{ $t('setupAccount.success.openDashboardAction') }}</Button>
|
||||
|
||||
@@ -59,7 +59,7 @@ defineExpose({
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.accessControl.sftp.port') }}</div>
|
||||
<div class="info-value">222 <ClipboardAction plain :value="222" /></div>
|
||||
<div class="info-value">222 <ClipboardAction plain value="222" /></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
|
||||
@@ -6,7 +6,7 @@ const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardAction, Menu, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
import Section from '../components/Section.vue';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
@@ -56,6 +56,25 @@ const columns = {
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const backupContentTableColumns = {
|
||||
label: {
|
||||
label: t('backups.listing.contents'),
|
||||
sort: true,
|
||||
},
|
||||
fileCount: {
|
||||
label: t('backup.target.fileCount'),
|
||||
sort(a, b, A, B) {
|
||||
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort(a, b, A , B) {
|
||||
return A.stats?.upload?.size - B.stats?.upload?.size;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
@@ -164,16 +183,7 @@ async function refreshBackups() {
|
||||
|
||||
result.forEach(function (backup) {
|
||||
backup.site = sites.value.find(t => t.id === backup.siteId);
|
||||
|
||||
// filled when opening the info dialog - we only show apps for the moment
|
||||
backup.contents = backup.dependsOn.filter(c => c.indexOf('app_') === 0).map(c => {
|
||||
return {
|
||||
id: c,
|
||||
label: null,
|
||||
fqdn: null,
|
||||
stats: null
|
||||
};
|
||||
});
|
||||
backup.appCount = backup.dependsOn.filter(c => c.indexOf('app_') === 0).length;
|
||||
});
|
||||
|
||||
backups.value = result;
|
||||
@@ -192,9 +202,13 @@ async function onDownloadConfig(backup) {
|
||||
|
||||
// backups info dialog
|
||||
const infoDialog = useTemplateRef('infoDialog');
|
||||
const infoDialogBusy = ref(true);
|
||||
const infoBackup = ref({ contents: [] });
|
||||
async function onInfo(backup) {
|
||||
infoBackup.value = backup;
|
||||
infoBackup.value.contents = [];
|
||||
infoDialogBusy.value = true;
|
||||
|
||||
infoDialog.value.open();
|
||||
|
||||
// amend detailed app info
|
||||
@@ -207,21 +221,30 @@ async function onInfo(backup) {
|
||||
appsById[app.id] = app;
|
||||
});
|
||||
|
||||
for (const content of infoBackup.value.contents) {
|
||||
const match = content.id.match(/app_(.*?)_.*/); // *? means non-greedy
|
||||
for (const contentId of infoBackup.value.dependsOn) {
|
||||
const match = contentId.match(/(mail|app)_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) continue;
|
||||
const [error, backup] = await backupsModel.get(content.id);
|
||||
const [error, backup] = await backupsModel.get(contentId);
|
||||
if (error) console.error(error);
|
||||
const content = { id: null, label: null, fqdn: null, stats: null };
|
||||
content.stats = backup.stats;
|
||||
const app = appsById[match[1]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
if (match[1] === 'mail') {
|
||||
content.id = 'mail';
|
||||
content.label = 'Mail Server';
|
||||
} else {
|
||||
content.id = match[1];
|
||||
const app = appsById[match[2]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
} else { // uninstalled app
|
||||
content.id = match[2];
|
||||
}
|
||||
}
|
||||
infoBackup.value.contents.push(content);
|
||||
}
|
||||
|
||||
infoDialogBusy.value = false;
|
||||
}
|
||||
|
||||
// edit backups dialog
|
||||
@@ -308,15 +331,35 @@ defineExpose({ refresh });
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ infoBackup.packageVersion }}</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="infoBackup.stats?.aggregatedUpload">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
|
||||
<div class="info-value">{{ prettyFileSize(infoBackup.stats.aggregatedUpload.size) }} | {{ infoBackup.stats.aggregatedUpload.fileCount }} file(s)</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="infoBackup.stats?.aggregatedCopy">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
|
||||
<div class="info-value">{{ prettyDuration(infoBackup.stats.aggregatedUpload.duration + infoBackup.stats.aggregatedCopy.duration) }}</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div>{{ $t('backups.backupDetails.list', { appCount: infoBackup.appCount }) }}:</div>
|
||||
<br/>
|
||||
|
||||
<p class="text-muted">{{ $t('backups.backupDetails.list', { appCount: infoBackup.contents.length }) }}:</p>
|
||||
<div v-for="content in infoBackup.contents" :key="content.id">
|
||||
<a v-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
|
||||
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
|
||||
<span> {{ prettyFileSize(content.stats.size) }} - {{ content.stats.fileCount }} file(s)</span>
|
||||
</div>
|
||||
<TableView :columns="backupContentTableColumns" :model="infoBackup.contents" :busy="infoDialogBusy">
|
||||
<template #label="content">
|
||||
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
|
||||
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
|
||||
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
|
||||
</template>
|
||||
<template #fileCount="content">
|
||||
<div v-if="content.stats?.upload" style="text-align: right">{{ content.stats.upload.fileCount }}</div>
|
||||
<div v-else style="text-align: right">-</div>
|
||||
</template>
|
||||
<!-- <td>{{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }}</td> -->
|
||||
<template #size="content">
|
||||
<div v-if="content.stats?.upload" style="text-align: right">{{ prettyFileSize(content.stats.upload.size) }}</div>
|
||||
<div v-else style="text-align: right">-</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="editDialog"
|
||||
@@ -355,12 +398,12 @@ defineExpose({ refresh });
|
||||
<template #creationTime="backup">{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b></template>
|
||||
|
||||
<template #content="backup">
|
||||
<span v-if="backup.contents.length">{{ $t('backups.listing.appCount', { appCount: backup.contents.length }) }}</span>
|
||||
<span v-if="backup.appCount">{{ $t('backups.listing.appCount', { appCount: backup.appCount }) }}</span>
|
||||
<span v-else>{{ $t('backups.listing.noApps') }}</span>
|
||||
</template>
|
||||
|
||||
<template #size="backup">
|
||||
<span v-if="backup.stats.aggregated">{{ prettyFileSize(backup.stats.aggregated.size) }} - {{ backup.stats.aggregated.fileCount }} file(s)</span>
|
||||
<span v-if="backup.stats?.aggregatedUpload">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
|
||||
<template #site="backup">{{ backup.site.name }}</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import SystemModel from '../models/SystemModel.js';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import GraphItem from './GraphItem.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { getColor } from '../utils.js';
|
||||
|
||||
const systemModel = SystemModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
@@ -85,21 +86,9 @@ async function liveRefresh() {
|
||||
};
|
||||
}
|
||||
|
||||
function generateConsistentColors(n, saturation = 90, lightness = 90) {
|
||||
const baseHue = 204; // from #9ad0f5 → hsl(204,82%,78%)
|
||||
const colors = [];
|
||||
const step = 360 / n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const hue = Math.round((baseHue + step * i) % 360); // rotate hue, wrap at 360
|
||||
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
function createDatasets() {
|
||||
const colors = generateConsistentColors((selectedContainers.value.length+1)*2); // 1 for the 'system'
|
||||
const colorCount = (selectedContainers.value.length+1)*2; // 1 for the 'system'
|
||||
const colors = Array.from({ length: colorCount }).map((e, idx) => getColor(colorCount, idx));
|
||||
|
||||
const datasets = {
|
||||
cpu: [],
|
||||
@@ -203,7 +192,8 @@ onUnmounted(async () => {
|
||||
<template>
|
||||
<Section :title="$t('system.graphs.title')">
|
||||
<template #header-buttons>
|
||||
<MultiSelect @select="rebuild()" v-model="selectedContainers" :options="allContainers" option-label="label" :search-threshold="20" select-all-label="Select All"/>
|
||||
<!-- do not rebuild on @select because rebuild is not reentrant! -->
|
||||
<MultiSelect @close="rebuild()" v-model="selectedContainers" :options="allContainers" option-label="label" :search-threshold="20" select-all-label="Select All"/>
|
||||
<SingleSelect @select="rebuild()" v-model="period" :options="periods" option-label="label"/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ async function onReboot() {
|
||||
confirmLabel: t('main.rebootDialog.rebootAction'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox } from '@cloudron/pankow';
|
||||
import { Button, FormGroup, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox, InputDialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES, ISTATES } from '../constants.js';
|
||||
import Section from '../components/Section.vue';
|
||||
@@ -37,8 +41,10 @@ function prettyAutoUpdateSchedule(pattern) {
|
||||
}
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const updateDialog = useTemplateRef('updateDialog');
|
||||
|
||||
const ready = ref(false);
|
||||
const taskLogsMenu = ref([]);
|
||||
const apps = ref([]);
|
||||
const version = ref('');
|
||||
@@ -77,9 +83,6 @@ async function refreshAutoupdatePattern() {
|
||||
const [error, result] = await updaterModel.getAutoupdatePattern();
|
||||
if (error) return console.error(error);
|
||||
|
||||
// just keep the UI sane by supporting previous default pattern
|
||||
if (result.pattern === '00 30 1,3,5,23 * * *') result.pattern = '00 15 1,3,5,23 * * *';
|
||||
|
||||
currentPattern.value = result.pattern;
|
||||
configurePattern.value = result.pattern;
|
||||
}
|
||||
@@ -104,10 +107,12 @@ async function refreshPendingUpdateInfo() {
|
||||
|
||||
function onShowConfigure() {
|
||||
configureType.value = configurePattern.value === 'never' ? 'never' : 'pattern';
|
||||
|
||||
const tmp = currentPattern.value.split(' ');
|
||||
const hours = tmp[2] ? tmp[2].split(',') : [];
|
||||
const days = tmp[5] ? tmp[5].split(',') : [];
|
||||
if (days[0] === '*') configureDays.value = cronDays;
|
||||
|
||||
if (days[0] === '*') configureDays.value = cronDays.map(day => { return day.id; });
|
||||
else configureDays.value = days.map(day => { return parseInt(day, 10); });
|
||||
|
||||
try {
|
||||
@@ -201,8 +206,17 @@ async function onSubmitUpdate() {
|
||||
|
||||
const [error] = await updaterModel.update(skipBackup.value);
|
||||
if (error) {
|
||||
updateError.value.generic = error.message || 'Internal error';
|
||||
updateBusy.value = false;
|
||||
|
||||
updateDialog.value.close();
|
||||
|
||||
inputDialog.value.info({
|
||||
title: t('notifications.settings.cloudronUpdateFailed'),
|
||||
message: error.body ? error.body.message : 'Internal error. Please try again.',
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
confirmStyle: 'secondary'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,12 +265,16 @@ onMounted(async () => {
|
||||
await refreshPendingUpdateInfo();
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshTasks();
|
||||
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
|
||||
<Dialog ref="updateDialog"
|
||||
:title="$t('settings.updateDialog.title') + ` v${pendingUpdate ? pendingUpdate.version : ''}`"
|
||||
:confirm-label="$t('settings.updateDialog.updateAction')"
|
||||
@@ -270,7 +288,7 @@ onMounted(async () => {
|
||||
>
|
||||
<div v-if="pendingUpdate">
|
||||
<div v-if="canUpdate">
|
||||
<p class="text-danger" v-if="pendingUpdate.unstable">{{ $t('settings.updateDialog.unstableWarning') }}</p>
|
||||
<p v-if="pendingUpdate.unstable" class="error-label">{{ $t('settings.updateDialog.unstableWarning') }}</p>
|
||||
|
||||
<div>{{ $t('settings.updateDialog.changes') }}:</div>
|
||||
<div class="changelog-container">
|
||||
@@ -280,8 +298,6 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
|
||||
|
||||
<p v-if="updateError.generic" class="error-label">{{ updateError.generic }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -304,18 +320,20 @@ onMounted(async () => {
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitConfigure()"
|
||||
>
|
||||
<p v-html="$t('settings.updateScheduleDialog.description')"></p>
|
||||
<FormGroup>
|
||||
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
|
||||
|
||||
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
|
||||
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
|
||||
|
||||
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
|
||||
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')" style="margin-top: 10px"/>
|
||||
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
|
||||
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')"/>
|
||||
|
||||
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin-top: 10px">
|
||||
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="value"/></div>
|
||||
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="value"/></div>
|
||||
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin: 10px 0px 0px 25px">
|
||||
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('settings.updates.title')">
|
||||
@@ -326,7 +344,7 @@ onMounted(async () => {
|
||||
<div v-html="$t('settings.updates.description')"></div>
|
||||
<br/>
|
||||
|
||||
<SettingsItem>
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(currentPattern) || '-' }}</span>
|
||||
@@ -337,15 +355,22 @@ onMounted(async () => {
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('system.info.cloudronVersion') }}</label>
|
||||
<span>{{ version }} <span v-if="!pendingUpdate">({{ $t('settings.updates.onLatest') }})</span></span>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<ProgressBar :value="lastTask.percent" v-if="updateBusy && lastTask" :busy="true" />
|
||||
<p v-if="updateBusy && lastTask">{{ lastTask.message }}</p>
|
||||
|
||||
<div class="error-label" v-if="stopError.generic">{{ stopError.generic }}</div>
|
||||
<div class="error-label" v-if="updateCheckError.generic">{{ updateCheckError.generic }}</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
|
||||
<div class="button-bar" v-if="ready">
|
||||
<Button :disabled="checkingBusy" :loading="checkingBusy" v-if="!updateBusy" @click="onCheck()">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
|
||||
<Button :danger="(pendingUpdate && pendingUpdate.unstable) ? true : undefined" :success="(pendingUpdate && !pendingUpdate.unstable) ? true : undefined" v-show="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -366,12 +391,7 @@ onMounted(async () => {
|
||||
.changelog-container {
|
||||
overflow: auto;
|
||||
max-height: 20lh;
|
||||
margin-bottom: 10px;
|
||||
padding-right: 0.5rem; /* space so scrollbar doesn’t overlap text */
|
||||
}
|
||||
|
||||
.skip-backup {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -46,11 +46,13 @@ async function onDownload() {
|
||||
downloadFileDownloadUrl.value = '';
|
||||
|
||||
const downloadFileName = await inputDialog.value.prompt({
|
||||
message: t('terminal.downloadAction'),
|
||||
title: t('terminal.download.title'),
|
||||
message: t('terminal.download.description'),
|
||||
value: '',
|
||||
confirmStyle: 'success',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('terminal.download.download'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!downloadFileName) return;
|
||||
|
||||
@@ -228,7 +228,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="user ? $t('users.editUserDialog.title', { username: (user.username || user.email) }) : $t('users.addUserDialog.title')"
|
||||
:title="user ? $t('users.editUserDialog.title') : $t('users.addUserDialog.title')"
|
||||
:confirm-label="user ? $t('main.dialog.save') : $t('users.addUserDialog.addUserAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy"
|
||||
@@ -255,23 +255,23 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="emailInput" v-model="email" :disabled="(user && user.source) ? true : null" required />
|
||||
<div class="text-danger" v-if="formError.email">{{ formError.email }}</div>
|
||||
<!-- if profile edit is locked a user has to be set here . username is editable until one is set -->
|
||||
<FormGroup :has-error="formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" :readonly="user?.username ? true : undefined" />
|
||||
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
|
||||
<div class="text-danger" v-if="formError.username">{{ formError.username }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- if profile edit is locked a user has to be set here . username is editable until one is set -->
|
||||
<FormGroup v-if="!user || !user.username" :has-error="formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" />
|
||||
<small class="helper-text">{{ profileLocked ? '' : $t('users.user.usernamePlaceholder') }}</small>
|
||||
<div class="text-danger" v-if="formError.username">{{ formError.username }}</div>
|
||||
<FormGroup>
|
||||
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="emailInput" v-model="email" :readonly="(user && user.source) ? true : undefined" required />
|
||||
<div class="text-danger" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup style="flex-grow: 1">
|
||||
<label for="displayNameInput">{{ $t('users.user.fullName') }}</label>
|
||||
<TextInput id="displayNameInput" v-model="displayName" :disabled="(user && user.source) ? true : null"/>
|
||||
<TextInput id="displayNameInput" v-model="displayName" :readonly="(user && user.source) ? true : undefined"/>
|
||||
<small v-if="!user || !user.username" class="helper-text">{{ $t('users.user.displayNamePlaceholder') }}</small> <!-- don't show if user has already signed up -->
|
||||
</FormGroup>
|
||||
|
||||
@@ -294,7 +294,8 @@ defineExpose({
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
|
||||
<!-- on add, this is hidden for now, until we figure why one would want to add an inactive user -->
|
||||
<Checkbox v-if="user" v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
|
||||
<Checkbox v-if="!user" v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -6,39 +6,64 @@ import AccessControl from '../AccessControl.vue';
|
||||
import OperatorAccessControl from '../OperatorAccessControl.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import { ACL_OPTIONS } from '../../constants.js';
|
||||
import UsersModel from '../../models/UsersModel.js';
|
||||
import GroupsModel from '../../models/GroupsModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const busy = ref(false);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
const loading = ref(false);
|
||||
const submitBusy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const accessRestrictionOption = ref(ACL_OPTIONS.ANY);
|
||||
const accessRestrictionAcl = ref({ users: [], groups: [] });
|
||||
const operatorAcl = ref({ users: [], groups: [] });
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
submitBusy.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
let [error] = await appsModel.configure(props.app.id, 'access_restriction', { accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? false : accessRestrictionAcl.value) });
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
submitBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
[error] = await appsModel.configure(props.app.id, 'operators', { operators: (operatorAcl.value.users.length || operatorAcl.value.groups.length) ? operatorAcl.value : null});
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
submitBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
submitBusy.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
const userIds = new Set();
|
||||
for (const u of result) {
|
||||
u.username = u.username || u.email; // ensure username
|
||||
userIds.add(u.id);
|
||||
}
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
const groupIds = new Set();
|
||||
for (const g of result) groupIds.add(g.id);
|
||||
|
||||
if (props.app.accessRestriction === null) {
|
||||
accessRestrictionOption.value = ACL_OPTIONS.ANY;
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
@@ -47,26 +72,31 @@ onMounted(() => {
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
} else {
|
||||
accessRestrictionOption.value = ACL_OPTIONS.RESTRICTED;
|
||||
accessRestrictionAcl.value = props.app.accessRestriction;
|
||||
accessRestrictionAcl.value = JSON.parse(JSON.stringify(props.app.accessRestriction)); // make a copy
|
||||
accessRestrictionAcl.value.users = accessRestrictionAcl.value.users.filter(uid => userIds.has(uid)); // remove deleted users
|
||||
accessRestrictionAcl.value.groups = accessRestrictionAcl.value.groups.filter(gid => groupIds.has(gid)); // remove deleted groups
|
||||
}
|
||||
|
||||
operatorAcl.value = { users: [], groups: [] };
|
||||
if (props.app.operators) {
|
||||
operatorAcl.value.users = props.app.operators.users;
|
||||
operatorAcl.value.groups = props.app.operators.groups;
|
||||
operatorAcl.value = JSON.parse(JSON.stringify(props.app.operators)); // make a copy
|
||||
operatorAcl.value.users = operatorAcl.value.users.filter(uid => userIds.has(uid)); // remove deleted users
|
||||
operatorAcl.value.groups = operatorAcl.value.groups.filter(gid => groupIds.has(gid)); // remove deleted groups
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!loading">
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="app.manifest" :hide-optional-sso-option="!app.sso"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :sso="app.sso" :installation="false"/>
|
||||
<br/>
|
||||
<OperatorAccessControl v-model:acl="operatorAcl" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
|
||||
<OperatorAccessControl v-model:acl="operatorAcl" :users="users" :groups="groups" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button @click="onSubmit()" :loading="submitBusy" :disabled="submitBusy">{{ $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,7 +70,7 @@ function onActionMenu(backup, event) {
|
||||
icon: 'fa-solid fa-download',
|
||||
label: t('app.backups.backups.downloadBackupTooltip'),
|
||||
visible: backup.site.format === 'tgz' && props.app.accessLevel === 'admin',
|
||||
action: getDownloadLink.bind(null, backup),
|
||||
href: getDownloadLink(backup),
|
||||
}, {
|
||||
icon: 'fa-solid fa-file-alt',
|
||||
label: t('app.backups.backups.downloadConfigTooltip'),
|
||||
@@ -352,8 +352,8 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="restoreDialog"
|
||||
:title="$t('app.restoreDialog.title', { app: app.fqdn })"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:title="$t('app.restoreDialog.title')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('app.restoreDialog.restoreAction')"
|
||||
:confirm-active="true"
|
||||
@@ -361,16 +361,15 @@ onMounted(async () => {
|
||||
@confirm="onRestoreSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p>{{ $t('app.restoreDialog.description', { creationTime: prettyLongDate(restoreBackup.creationTime) }) }}</p>
|
||||
<p class="text-danger">{{ $t('app.restoreDialog.warning') }}</p>
|
||||
<br/>
|
||||
<p>{{ $t('app.restoreDialog.description', { fqdn: app.fqdn, creationTime: prettyLongDate(restoreBackup.creationTime) }) }}</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.backups.auto.title') }}</label>
|
||||
<div v-html="$t('app.backups.auto.description', { backupLink: '/#/backups' })"></div>
|
||||
<div v-html="$t('app.backups.auto.description')"></div>
|
||||
</FormGroup>
|
||||
<Switch v-model="autoBackupsEnabled" @change="onChangeAutoBackups"/>
|
||||
</SettingsItem>
|
||||
@@ -425,7 +424,7 @@ onMounted(async () => {
|
||||
{{ backup.site.name }}
|
||||
</template>
|
||||
<template #size="backup">
|
||||
<span v-if="backup.stats">{{ prettyFileSize(backup.stats.size) }} - {{ backup.stats.fileCount }} file(s)</span>
|
||||
<span v-if="backup.stats?.upload">{{ prettyFileSize(backup.stats.upload.size) }} - {{ backup.stats.upload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
|
||||
@@ -89,7 +89,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<div style="display: inline-block;">
|
||||
<label>{{ $t('app.display.icon') }}</label>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" fallback-src="/img/appicon_fallback.png" @changed="onIconChanged" size="512" display-height="96px"/>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" fallback-src="/img/appicon_fallback.png" @changed="onIconChanged" :size="512" display-height="96px"/>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
|
||||
@@ -128,21 +128,21 @@ onMounted(async () => {
|
||||
<div>
|
||||
<div v-if="hasSendmail">
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.email.from.title') }} <sup><a href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label>{{ $t('app.email.configuration.title') }} <sup><a href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<Radiobutton v-if="sendmailOptional" v-model="enableMailbox" :value="1" :label="$t('app.email.from.enable')"/>
|
||||
|
||||
<div style="margin-bottom: 18px;" :style="{ 'padding-left': sendmailOptional ? '25px' : '0' }">
|
||||
<div v-html="$t('app.email.from.enableDescription', { domain: app.domain, domainConfigLink: ('/#/email/' + app.domain) })"></div>
|
||||
<br/>
|
||||
<div v-html="$t('app.email.from.enableDescription', { domain: app.domain, domainConfigLink: ('/#/email-domain/' + app.domain) })"></div>
|
||||
|
||||
<form @submit.prevent="onSendmailSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="enableMailbox === 0 || sendmailBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">
|
||||
<fieldset :disabled="enableMailbox === 0 || sendmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">
|
||||
<input type="submit" style="display: none;" :disabled="!sendmailMailboxName"/>
|
||||
|
||||
<FormGroup>
|
||||
<div class="has-error" v-if="sendmailError">{{ sendmailError }}</div>
|
||||
|
||||
<label>{{ $t('app.email.from.title') }}</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<TextInput v-if="sendmailSupportsDisplayName" v-model="sendmailDisplayName" :placeholder="$t('app.email.from.displayName')"/>
|
||||
<InputGroup>
|
||||
@@ -159,8 +159,8 @@ onMounted(async () => {
|
||||
<div v-if="sendmailOptional" style="padding-left: 25px;">{{ $t('app.email.from.disableDescription') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSendmailSubmit()" :loading="sendmailBusy" :disabled="sendmailBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
<br v-if="sendmailOptional"/>
|
||||
<Button @click="onSendmailSubmit()" :loading="sendmailBusy" :disabled="sendmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
</div>
|
||||
|
||||
<hr style="margin-top: 20px" v-if="hasSendmail && hasRecvmail"/>
|
||||
@@ -184,7 +184,7 @@ onMounted(async () => {
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onRecvmailSubmit()" :disabled="recvmailBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId" :loading="recvmailBusy">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
<Button @click="onRecvmailSubmit()" :disabled="recvmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId" :loading="recvmailBusy">{{ $t('app.email.from.saveAction') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,9 +53,9 @@ onMounted(async () => {
|
||||
<table class="eventlog-table pankow-no-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('eventlog.time') }}</th>
|
||||
<th>{{ $t('eventlog.source') }}</th>
|
||||
<th>{{ $t('eventlog.details') }}</th>
|
||||
<th style="width: 160px">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%">{{ $t('eventlog.source') }}</th>
|
||||
<th style="word-break: break-all; overflow-wrap: anywhere;">{{ $t('eventlog.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -66,7 +66,7 @@ onMounted(async () => {
|
||||
<td v-html="eventlog.details"></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="4" class="eventlog-details">
|
||||
<td colspan="3" class="eventlog-details">
|
||||
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</td>
|
||||
|
||||
@@ -81,7 +81,7 @@ onMounted(() => {
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.description') }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.version }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
|
||||
<div class="info-value" v-else>{{ app.manifest.dockerImage }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, inject } from 'vue';
|
||||
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { isValidDomain } from '@cloudron/pankow/utils';
|
||||
import { ISTATES } from '../../constants.js';
|
||||
@@ -13,6 +13,7 @@ const props = defineProps([ 'app' ]);
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const domains = ref([]);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
@@ -38,7 +39,7 @@ function isNoopOrManual(domain) {
|
||||
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
domain: domains.value[0].domain,
|
||||
domain: dashboardDomain.value,
|
||||
subdomain: ''
|
||||
});
|
||||
}
|
||||
@@ -49,7 +50,7 @@ function onRemoveAlias(index) {
|
||||
|
||||
function onAddRedirect() {
|
||||
redirects.value.push({
|
||||
domain: domains.value[0].domain,
|
||||
domain: dashboardDomain.value,
|
||||
subdomain: ''
|
||||
});
|
||||
}
|
||||
@@ -63,8 +64,16 @@ const formValid = computed(() => {
|
||||
}];
|
||||
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
|
||||
for (const d of aliases.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of aliases.value) {
|
||||
let subdomain = d.subdomain;
|
||||
// see apps.js:validateLocations()
|
||||
if (d.subdomain.startsWith('*')) {
|
||||
if (subdomain === '*') continue;
|
||||
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
|
||||
}
|
||||
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
|
||||
}
|
||||
|
||||
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
|
||||
|
||||
@@ -189,7 +198,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" novalidate>
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.details.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
|
||||
<input type="submit" style="display: none;" :disabled="(app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.location.location') }}</label>
|
||||
@@ -217,10 +226,9 @@ onMounted(async () => {
|
||||
|
||||
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="errorObject" :domain-provider="domainProvider"/>
|
||||
|
||||
<div v-if="app.manifest.multiDomain" style="margin-top: 20px">
|
||||
<FormGroup v-if="app.manifest.multiDomain">
|
||||
<label>{{ $t('app.location.aliases') }} <sup><a href="https://docs.cloudron.io/apps/#aliases" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<div v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}</div>
|
||||
<div v-for="(item, index) in aliases" :key="item" style="margin-bottom: 10px">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
@@ -232,13 +240,14 @@ onMounted(async () => {
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</div>
|
||||
|
||||
<div class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}. </span>
|
||||
<span class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<div style="margin-top: 20px">
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.location.redirections') }} <sup><a href="https://docs.cloudron.io/apps/#redirections" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<div v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}</div>
|
||||
<div v-for="(item, index) in redirects" :key="item" style="margin-bottom: 10px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
@@ -250,18 +259,29 @@ onMounted(async () => {
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</div>
|
||||
|
||||
<div class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}. </span>
|
||||
<span class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div class="has-error" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
|
||||
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
|
||||
<br v-if="needsOverwriteDns"/>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.details.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pankow-form-group small {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,7 +73,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.repair.recovery.title') }}</label>
|
||||
<div v-html="$t('app.repair.recovery.description', { docsLink: 'https://docs.cloudron.io/apps/#recovery-mode' })"></div>
|
||||
<br/>
|
||||
<Button @click="onToggleDebugMode" :disabled="debugModeBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_DEBUG) || app.taskId">
|
||||
<Button @click="onToggleDebugMode" :disabled="debugModeBusy || (app.error && app.error.installationState !== ISTATES.PENDING_DEBUG) || app.taskId">
|
||||
<span v-if="app.debugMode">{{ $t('app.repair.recovery.disableAction') }}</span>
|
||||
<span v-else>{{ $t('app.repair.recovery.enableAction') }}</span>
|
||||
</Button>
|
||||
@@ -84,9 +84,9 @@ onMounted(() => {
|
||||
<div>
|
||||
<label>{{ $t('app.repair.taskError.title') }}</label>
|
||||
<div>{{ $t('app.repair.taskError.description') }}</div>
|
||||
<div v-if="app.error" style="margin-top: 10px;">An error occurred during the <b>{{ taskNameFromInstallationState(app.error.details.installationState) }}</b> operation: <span class="text-danger"><b>{{ app.error.reason + ': ' + app.error.message }}</b></span></div>
|
||||
<div v-if="app.error" style="margin-top: 10px;">An error occurred during the <b>{{ taskNameFromInstallationState(app.error.installationState) }}</b> operation: <span class="text-danger"><b>{{ app.error.reason + ': ' + app.error.message }}</b></span></div>
|
||||
<br/>
|
||||
<Button @click="onRepair()" :disabled="busyRepair || app.taskId || !app.error" :loading="busyRepair">{{ $t('app.repair.taskError.retryAction', { task: app.error ? taskNameFromInstallationState(app.error.details.installationState) : '' }) }}</Button>
|
||||
<Button @click="onRepair()" :disabled="busyRepair || app.taskId || !app.error" :loading="busyRepair">{{ $t('app.repair.taskError.retryAction', { task: app.error ? taskNameFromInstallationState(app.error.installationState) : '' }) }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,20 +115,20 @@ onMounted(async () => {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('app.resources.memory.title') }} <sup><a href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ prettyBinarySize(memoryLimit, 'Default (256 MiB)') }}</b></label>
|
||||
<p>{{ $t('app.resources.memory.description') }}</p>
|
||||
<div description>{{ $t('app.resources.memory.description') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" step="134217728" :min="memoryTicks[0]" :max="memoryTicks[memoryTicks.length-1]" list="memoryLimitTicks" />
|
||||
<datalist id="memoryLimitTicks">
|
||||
<option v-for="value of memoryTicks" :key="value" :value="value"></option>
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<Button @click="onSubmitMemoryLimit()" :loading="memoryLimitBusy" :disabled="memoryLimitBusy || (!app.error && memoryLimit === currentMemoryLimit) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.memory.resizeAction') }}</Button>
|
||||
<Button @click="onSubmitMemoryLimit()" :loading="memoryLimitBusy" :disabled="memoryLimitBusy || (!app.error && memoryLimit === currentMemoryLimit) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.memory.resizeAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="cpuQuotaInput">{{ $t('app.resources.cpu.title') }} <sup><a href="https://docs.cloudron.io/apps/#cpu-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ cpuQuota + ' %' }}</b></label>
|
||||
<p>{{ $t('app.resources.cpu.description') }}</p>
|
||||
<div description>{{ $t('app.resources.cpu.description') }}</div>
|
||||
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" />
|
||||
<datalist id="cpuQuotaTicks">
|
||||
<option value="25"></option>
|
||||
@@ -137,21 +137,22 @@ onMounted(async () => {
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<Button @click="onSubmitCpuQuota()" :loading="cpuQuotaBusy" :disabled="cpuQuotaBusy || (!app.error && cpuQuota === currentCpuQuota) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.cpu.setAction') }}</Button>
|
||||
<Button @click="onSubmitCpuQuota()" :loading="cpuQuotaBusy" :disabled="cpuQuotaBusy || (!app.error && cpuQuota === currentCpuQuota) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.cpu.setAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<form @submit.prevent="onSubmitDevices()" autocomplete="off">
|
||||
<fieldset :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">
|
||||
<fieldset :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">
|
||||
<input style="display: none;" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="devicesInput">{{ $t('app.resources.devices.label') }} <sup><a href="https://docs.cloudron.io/apps/#devices" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div description>{{ $t('app.resources.devices.description') }}</div>
|
||||
<TagInput id="devicesInput" v-model="devices" placeholder="/dev/ttyUSB0, /dev/hidraw0, ..."/>
|
||||
<div class="text-danger" v-if="devicesError">{{ devicesError }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
<br/>
|
||||
<Button @click="onSubmitDevices()" :loading="devicesBusy" :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.details.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button @click="onSubmitDevices()" :loading="devicesBusy" :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">{{ $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,7 +51,8 @@ onMounted(() => {
|
||||
<span>{{ $t('app.security.robots.title') }} <sup><a href="https://docs.cloudron.io/apps/#robotstxt" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></span>
|
||||
<Button small outline @click="onAddDisableIndexing()">{{ $t('app.security.robots.disableIndexingAction') }}</Button>
|
||||
</label>
|
||||
<textarea id="robotsTxtInput" style="white-space: pre-wrap; font-family: monospace;" v-model="robotsTxt" rows="10" :placeholder="$t('app.security.robots.txtPlaceholder')"></textarea>
|
||||
<div description>{{ $t('app.security.robots.description') }}</div>
|
||||
<textarea id="robotsTxtInput" style="white-space: pre-wrap; font-family: monospace;" v-model="robotsTxt" rows="10"></textarea>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
@@ -60,9 +61,9 @@ onMounted(() => {
|
||||
<textarea id="cspInput" style="white-space: pre-wrap; font-family: monospace;" v-model="csp" placeholder="default-src 'self'; frame-ancestors 'none';" rows="2"></textarea>
|
||||
</FormGroup>
|
||||
|
||||
<div>
|
||||
<FormGroup>
|
||||
<Checkbox v-model="hstsPreload" style="display: inline-flex;" :label="$t('app.security.hstsPreload')" help-url="https://docs.cloudron.io/apps/#hsts-preload"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy">{{ $t('app.security.csp.saveAction') }}</Button>
|
||||
|
||||
@@ -58,7 +58,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.turn.title') }} <sup><a href="https://docs.cloudron.io/apps/#turn" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>{{ $t('app.turn.info') }}</div>
|
||||
</div>
|
||||
<Switch @change="onTurnChange" v-model="turnEnabled" :disabled="turnBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId"/>
|
||||
<Switch @change="onTurnChange" v-model="turnEnabled" :disabled="turnBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem v-if="hasOptionalRedis">
|
||||
@@ -66,7 +66,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.redis.title') }} <sup><a href="https://docs.cloudron.io/apps/#redis" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>{{ $t('app.redis.info') }}</div>
|
||||
</div>
|
||||
<Switch @change="onRedisChange" v-model="redisEnabled" :disabled="redisBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId"/>
|
||||
<Switch @change="onRedisChange" v-model="redisEnabled" :disabled="redisBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId"/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -165,7 +165,7 @@ onMounted(async () => {
|
||||
<div description v-html="$t('app.storage.appdata.description', { storagePath: ('/home/yellowtent/appsdata/' + app.id) })"></div>
|
||||
|
||||
<form @submit.prevent="onSubmitMove()" autocomplete="off">
|
||||
<fieldset :disabled="moveBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_DATA_DIR_MIGRATION) || !!app.taskId">
|
||||
<fieldset :disabled="moveBusy || (app.error && app.error.installationState !== ISTATES.PENDING_DATA_DIR_MIGRATION) || !!app.taskId">
|
||||
<input type="submit" style="display: none"/>
|
||||
|
||||
<FormGroup>
|
||||
@@ -185,15 +185,14 @@ onMounted(async () => {
|
||||
<div v-if="moveError" class="error-label">{{ moveError }}</div>
|
||||
<br/>
|
||||
|
||||
<Button @click="onSubmitMove()" :loading="moveBusy" :disabled="moveBusy || (!app.error && originalVolumeId === volumeId) || (app.error && app.error.details.installationState !== ISTATES.PENDING_DATA_DIR_MIGRATION) || !!app.taskId">{{ $t('app.storage.appdata.moveAction') }}</Button>
|
||||
<Button @click="onSubmitMove()" :loading="moveBusy" :disabled="moveBusy || (!app.error && originalVolumeId === volumeId) || (app.error && app.error.installationState !== ISTATES.PENDING_DATA_DIR_MIGRATION) || !!app.taskId">{{ $t('app.storage.appdata.moveAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px;">
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.storage.mounts.title') }} <sup><a href="https://docs.cloudron.io/apps/#mounts" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div class="has-error" v-if="mountsError">{{ mountsError }}</div>
|
||||
<div v-html="$t('storage.mounts.description')"></div>
|
||||
<br/>
|
||||
<div description v-html="$t('storage.mounts.description')"></div>
|
||||
|
||||
<table class="table table-hover" style="margin-top: 10px;" v-if="mounts.length">
|
||||
<thead>
|
||||
@@ -213,20 +212,20 @@ onMounted(async () => {
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: right;">
|
||||
<Button tool small secondary v-show="mount.volumeId" :href="`/filemanager.html#/home/volume/${mount.volumeId}`" target="_blank" v-tooltip="$t('volumes.openFileManagerActionTooltip')" icon="fa-solid fa-folder"/>
|
||||
<Button tool small danger @click="onMountRemove(index)" icon="fa-solid fa-trash-alt" style="margin-left: 6px"/>
|
||||
<Button danger tool @click="onMountRemove(index)" icon="fa-solid fa-trash" style="margin-left: 6px"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 10px;">
|
||||
<span v-if="mounts.length === 0">{{ $t('app.storage.mounts.noMounts') }} </span>
|
||||
<span v-if="mounts.length === 0">{{ $t('app.storage.mounts.noMounts') }}. </span>
|
||||
<span class="actionable" @click="onMountAdd()">{{ $t('app.storage.mounts.addMountAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<br/>
|
||||
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || (app.error && app.error.details.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || !!app.taskId || (!app.error && !mountsChanged) || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
|
||||
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || !!app.taskId || (!app.error && !mountsChanged) || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,49 +18,14 @@ const props = defineProps([ 'app' ]);
|
||||
|
||||
const latestBackup = ref(null);
|
||||
|
||||
const TARGET_RUN_STATE = {
|
||||
START: Symbol('start'),
|
||||
STOP: Symbol('stop'),
|
||||
};
|
||||
|
||||
function targetRunState() {
|
||||
const app = props.app;
|
||||
|
||||
// if we have an error, we want to retry the pending state, otherwise toggle the runstate
|
||||
if (app.error) {
|
||||
if (app.error.details.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
} else {
|
||||
if (app.runState === RSTATES.STOPPED) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRunStateBusy = ref(false);
|
||||
async function onToggleRunState() {
|
||||
const app = props.app;
|
||||
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
let error;
|
||||
if (targetRunState() === TARGET_RUN_STATE.START) [error] = await appsModel.start(app.id);
|
||||
else [error] = await appsModel.stop(app.id);
|
||||
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
console.error(error);
|
||||
} else {
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function onUninstall() {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
title: t('app.uninstallDialog.title', { app: (props.app.label || props.app.fqdn) }),
|
||||
message: t('app.uninstallDialog.description', { app: (props.app.label || props.app.fqdn) }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('app.uninstallDialog.uninstallAction'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -91,10 +56,17 @@ async function onArchive() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await appsModel.backups(props.app.id);
|
||||
let [error, result] = await appsModel.backups(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
latestBackup.value = result[0] || null;
|
||||
|
||||
if (latestBackup.value) {
|
||||
[error, result] = await appsModel.listBackupSites(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
latestBackup.value.siteName = result.find((s) => s.id === latestBackup.value.siteId).name;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -103,24 +75,10 @@ onMounted(async () => {
|
||||
<div>
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<div>
|
||||
<label>{{ $t('app.uninstall.startStop.title') }}</label>
|
||||
<div>{{ $t('app.uninstall.startStop.description') }}</div>
|
||||
<br/>
|
||||
<Button @click="onToggleRunState()"
|
||||
:disabled="toggleRunStateBusy || !!app.taskId || (app.error && (app.error.details.installationState !== 'pending_start' && app.error.details.installationState !== 'pending_stop')) || app.installationState === 'pending_start' || app.installationState === 'pending_stop'"
|
||||
:loading="toggleRunStateBusy || app.installationState === 'pending_start' || app.installationState === 'pending_stop'"
|
||||
>
|
||||
{{ $t(targetRunState() === TARGET_RUN_STATE.START ? 'app.uninstall.startStop.startAction' : 'app.uninstall.startStop.stopAction') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr style="margin-top: 20px" v-if="app.type !== APP_TYPES.PROXIED"/>
|
||||
|
||||
<div v-if="app.type !== APP_TYPES.PROXIED">
|
||||
<label>{{ $t('app.archive.title') }}</label>
|
||||
<div v-html="$t('app.archive.description')"></div>
|
||||
<p class="text-bold text-success" v-if="latestBackup" v-html="$t('app.archive.latestBackupInfo', { date: prettyLongDate(latestBackup.creationTime) })"></p>
|
||||
<p class="text-bold text-success" v-if="latestBackup" v-html="$t('app.archive.latestBackupInfo', { date: prettyLongDate(latestBackup.creationTime), siteName: latestBackup.siteName })"></p>
|
||||
<p class="text-warning" v-else v-html="$t('app.archive.noBackup')"></p>
|
||||
<Button :disabled="!latestBackup" @click="onArchive()">{{ $t('app.archive.action') }}</Button>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<label>{{ $t('app.updates.auto.title') }}</label>
|
||||
<div v-if="!app.appStoreId">{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
|
||||
<div v-else>{{ $t('app.updates.auto.description') }}</div>
|
||||
<div v-else v-html="$t('app.updates.auto.description')"></div>
|
||||
</div>
|
||||
<Switch v-if="app.appStoreId" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
|
||||
</SettingsItem>
|
||||
@@ -146,7 +146,7 @@ onMounted(async () => {
|
||||
<div class="error-label" style="margin-top: 12px" v-if="app.updateInfo.unstable">{{ $t('app.updateDialog.unstableWarning') }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<Button v-if="app.updateInfo && features.appUpdates" :danger="app.updateInfo.unstable ? true : null" :success="app.updateInfo.unstable ? null : true" @click="onAskUpdate()" :disabled="app.taskId || (app.error && app.error.details.installationState !== ISTATES.PENDING_UPDATE) || app.runState === 'stopped' || app.installationState === 'pending_update'">{{ $t('app.updateDialog.updateAction') }}</Button>
|
||||
<Button v-if="app.updateInfo && features.appUpdates" :danger="app.updateInfo.unstable ? true : null" :success="app.updateInfo.unstable ? null : true" @click="onAskUpdate()" :disabled="app.taskId || (app.error && app.error.installationState !== ISTATES.PENDING_UPDATE) || app.runState === 'stopped' || app.installationState === 'pending_update'">{{ $t('app.updateDialog.updateAction') }}</Button>
|
||||
<Button v-else-if="app.updateInfo && !features.appUpdates && profile.isAtLeastOwner" success href="/#/cloudron-account">{{ $t('app.updateDialog.setupSubscriptionAction') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -73,7 +73,7 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<link rel="icon" href="<%- iconUrl %>?<%- Date.now() %>" type="image/png">
|
||||
<link rel="apple-touch-icon" href="<%- iconUrl %>?<%- Date.now() %>" type="image/png">
|
||||
<meta property="og:image" content="<%- iconUrl %>?<%- Date.now() %>">
|
||||
<% } else if (locals.dashboardFqdn) { -%>
|
||||
<link rel="icon" href="https://<%- dashboardFqdn %>/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
|
||||
<link rel="apple-touch-icon" href="https://<%- dashboardFqdn %>/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
|
||||
<meta property="og:image" content="https://<%- dashboardFqdn %>/api/v1/cloudron/avatar?<%- Date.now() %>">
|
||||
<% } else { -%>
|
||||
<link rel="icon" href="/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
|
||||
@@ -30,6 +34,9 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black">
|
||||
|
||||
<!-- Cloudron -->
|
||||
<meta name="application-name" creator="Cloudron" description="Cloudron Dashboard">
|
||||
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) {
|
||||
if (error.status === 404) return [];
|
||||
if (result.status === 404) return [];
|
||||
|
||||
console.error('Failed to list files', error || result.status);
|
||||
return [];
|
||||
|
||||
@@ -13,6 +13,7 @@ const providers = [
|
||||
{ name: 'GoDaddy', value: 'godaddy' },
|
||||
{ name: 'Google Cloud DNS', value: 'gcdns' },
|
||||
{ name: 'Hetzner', value: 'hetzner' },
|
||||
{ name: 'Hetzner Cloud', value: 'hetznercloud' },
|
||||
{ name: 'INWX', value: 'inwx' },
|
||||
{ name: 'Linode', value: 'linode' },
|
||||
{ name: 'Name.com', value: 'namecom' },
|
||||
@@ -53,6 +54,7 @@ function filterConfigForProvider(provider, config) {
|
||||
props = ['accessToken'];
|
||||
break;
|
||||
case 'hetzner':
|
||||
case 'hetznercloud':
|
||||
props = ['token'];
|
||||
break;
|
||||
case 'vultr':
|
||||
|
||||
@@ -9,7 +9,7 @@ function create() {
|
||||
async list(acknowledged = false) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 100 });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -53,8 +53,15 @@ h1, h2, h3, h4, h5 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -350,6 +357,12 @@ form .pankow-checkbox {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-spacing: 0px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.elide-table-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eventlog-table thead {
|
||||
|
||||
+10
-3
@@ -328,7 +328,7 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
return 'Backup cleaner started';
|
||||
|
||||
case ACTION_BACKUP_CLEANUP_FINISH:
|
||||
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backups';
|
||||
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backup(s)';
|
||||
|
||||
case ACTION_BACKUP_SITE_ADD:
|
||||
return `New backup site ${data.name} added with provider ${data.provider} and format ${data.format}`;
|
||||
@@ -671,6 +671,11 @@ const cronDays = [
|
||||
// generates 24h time sets (instead of american 12h) to avoid having to translate everything to locales eg. 12:00
|
||||
const cronHours = Array.from({ length: 24 }).map(function (v, i) { return { id: i, name: (i < 10 ? '0' : '') + i + ':00' }; });
|
||||
|
||||
function getColor(numOfSteps, step) {
|
||||
const deg = 360/numOfSteps;
|
||||
return `hsl(${deg*step} 70% 50%)`;
|
||||
}
|
||||
|
||||
// named exports
|
||||
export {
|
||||
prettyRelayProviderName,
|
||||
@@ -686,7 +691,8 @@ export {
|
||||
getDataURLFromFile,
|
||||
getTextFromFile,
|
||||
cronDays,
|
||||
cronHours
|
||||
cronHours,
|
||||
getColor,
|
||||
};
|
||||
|
||||
// default export
|
||||
@@ -704,5 +710,6 @@ export default {
|
||||
getDataURLFromFile,
|
||||
getTextFromFile,
|
||||
cronDays,
|
||||
cronHours
|
||||
cronHours,
|
||||
getColor,
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ onMounted(async () => {
|
||||
<div class="container">
|
||||
<div class="view">
|
||||
<h1 style="text-align: center;">Welcome to Cloudron</h1>
|
||||
<h3 style="text-align: center;">Set up Admin Account</h3>
|
||||
<h3 style="text-align: center;">Create Admin Account</h3>
|
||||
|
||||
<div class="has-error" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
|
||||
@@ -78,11 +78,12 @@ async function refreshArchives() {
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
async function onRemove(archive) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
title: t('backups.deleteArchiveDialog.title', { appTitle: archive.appConfig?.manifest?.title, fqdn: archive.appConfig?.fqdn }),
|
||||
message: t('backups.deleteArchiveDialog.description'),
|
||||
title: t('backups.deleteArchiveDialog.title'),
|
||||
message: t('backups.deleteArchiveDialog.description', { appTitle: archive.appConfig?.manifest?.title, appFqdn: archive.appConfig?.fqdn }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('backups.deleteArchive.deleteAction'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
@@ -151,6 +151,47 @@ function isViewEnabled(view, errorState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const TARGET_RUN_STATE = {
|
||||
START: Symbol('start'),
|
||||
STOP: Symbol('stop'),
|
||||
};
|
||||
|
||||
function targetRunState() {
|
||||
// if we have an error, we want to retry the pending state, otherwise toggle the runstate
|
||||
if (app.value.error) {
|
||||
if (app.value.error.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
} else {
|
||||
if (app.value.runState === RSTATES.STOPPED) return TARGET_RUN_STATE.START;
|
||||
else return TARGET_RUN_STATE.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRunStateBusy = ref(false);
|
||||
async function onStartApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.start(app.value.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
async function onStopApp() {
|
||||
toggleRunStateBusy.value = true;
|
||||
|
||||
const [error] = await appsModel.stop(app.value.id);
|
||||
if (error) {
|
||||
toggleRunStateBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => toggleRunStateBusy.value = false, 3000);
|
||||
}
|
||||
|
||||
async function onStopAppTask() {
|
||||
if (!app.value.taskId) return;
|
||||
|
||||
@@ -172,7 +213,7 @@ function hashChange() {
|
||||
if (parts.length !== 2) return;
|
||||
|
||||
const newView = parts[1] || 'info';
|
||||
if (!isViewEnabled(newView, app.value.error?.details.installationState)) {
|
||||
if (!isViewEnabled(newView, app.value.error?.installationState)) {
|
||||
if (!currentView.value) {
|
||||
currentView.value = 'info';
|
||||
window.location.hash = `/app/${id.value}/info`;
|
||||
@@ -204,7 +245,7 @@ onMounted(async () => {
|
||||
function buildMenuItem(id, label) {
|
||||
return {
|
||||
id: id,
|
||||
disabled: () => !isViewEnabled(id, app.value.error?.details.installationState),
|
||||
disabled: () => !isViewEnabled(id, app.value.error?.installationState),
|
||||
label: label,
|
||||
href: `/#/app/${id.value}/${id}`,
|
||||
};
|
||||
@@ -261,6 +302,14 @@ onBeforeUnmount(() => {
|
||||
<div class="titlebar-toolbar">
|
||||
<Button v-if="app.taskId" danger tool plain icon="fa-solid fa-xmark" v-tooltip="'Cancel Task'" :loading="busyStopTask" :disabled="busyStopTask" @click="onStopAppTask()"/>
|
||||
<Button :menu="views" secondary class="pankow-no-desktop" tool>{{ views.find(v => v.id === currentView).label }}</Button>
|
||||
|
||||
<!--
|
||||
TODO check if this should be shown on stop confirmation
|
||||
<div>{{ $t('app.uninstall.startStop.description') }}</div>
|
||||
-->
|
||||
<Button v-if="!app.progress && targetRunState() === TARGET_RUN_STATE.START" secondary tool icon="fa-solid fa-circle-play" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.startAction')" @click="onStartApp()"/>
|
||||
<Button v-else-if="!app.progress" secondary tool icon="fa-solid fa-circle-stop" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.stopAction')" @click="onStopApp()"/>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button secondary tool :href="`/logs.html?appId=${app.id}`" target="_blank" v-tooltip="$t('app.logsActionTooltip')" icon="fa-solid fa-align-left" />
|
||||
<Button secondary tool v-if="app.type !== APP_TYPES.PROXIED" :href="`/terminal.html?id=${app.id}`" target="_blank" v-tooltip="$t('app.terminalActionTooltip')" icon="fa fa-terminal" />
|
||||
@@ -274,8 +323,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="configure-body">
|
||||
<div class="configure-menu pankow-no-mobile">
|
||||
<div v-for="view in views" :key="view.id" class="configure-menu-item" :active="currentView === view.id ? true : null" :disabled="isViewEnabled(view.id, app.error?.details.installationState) ? null : true">
|
||||
<a v-if="isViewEnabled(view.id, app.error?.details.installationState)" :href="`/#/app/${app.id}/${view.id}`">{{ view.label }}</a>
|
||||
<div v-for="view in views" :key="view.id" class="configure-menu-item" :active="currentView === view.id ? true : null" :disabled="isViewEnabled(view.id, app.error?.installationState) ? null : true">
|
||||
<a v-if="isViewEnabled(view.id, app.error?.installationState)" :href="`/#/app/${app.id}/${view.id}`">{{ view.label }}</a>
|
||||
<span v-else>{{ view.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,6 +362,16 @@ onBeforeUnmount(() => {
|
||||
color: var(--pankow-text-color);
|
||||
}
|
||||
|
||||
.applink:focus,
|
||||
.applink:hover {
|
||||
color: var(--pankow-color-primary);
|
||||
}
|
||||
|
||||
.applink:not([href]) {
|
||||
cursor: not-allowed;
|
||||
color: var(--pankow-text-color) !important;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -356,10 +415,6 @@ onBeforeUnmount(() => {
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.applink:not([href]) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.configure-outer {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
|
||||
@@ -35,10 +35,7 @@ const tagFilterOptions = ref([{
|
||||
name: 'All Tags',
|
||||
}]);
|
||||
const domainFilter = ref('');
|
||||
const domainFilterOptions = ref([{
|
||||
id: '',
|
||||
domain: 'All Domains',
|
||||
}]);
|
||||
const domainFilterOptions = ref([]);
|
||||
const stateFilter = ref('');
|
||||
const stateFilterOptions = [
|
||||
{ id: '', label: 'All States' },
|
||||
@@ -49,7 +46,7 @@ const stateFilterOptions = [
|
||||
];
|
||||
const listColumns = {
|
||||
icon: {
|
||||
width: '32px'
|
||||
width: '40px'
|
||||
},
|
||||
label: {
|
||||
label: 'Label',
|
||||
@@ -57,7 +54,7 @@ const listColumns = {
|
||||
if (!fullA || !fullA) return -1;
|
||||
const checkA = fullA.label || fullA.subdomain || fullA.fqdn;
|
||||
const checkB = fullB.label || fullB.subdomain || fullB.fqdn;
|
||||
return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
|
||||
return checkA.toLowerCase() < checkB.toLowerCase() ? -1 : (checkA.toLowerCase() > checkB.toLowerCase() ? 1 : 0);
|
||||
},
|
||||
},
|
||||
fqdn: {
|
||||
@@ -65,18 +62,8 @@ const listColumns = {
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
status: {
|
||||
label: 'Status',
|
||||
hideMobile: true,
|
||||
sort: (a, b, fullA, fullB) => {
|
||||
if (!fullA || !fullA) return -1;
|
||||
const checkA = fullA.installationState + '-' + fullA.runState + '-' + fullA.health;
|
||||
const checkB = fullB.installationState + '-' + fullB.runState + '-' + fullB.health;
|
||||
return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
|
||||
},
|
||||
},
|
||||
appTitle: {
|
||||
label: 'App Title',
|
||||
label: 'App',
|
||||
hideMobile: true,
|
||||
sort: (a, b, fullA, fullB) => {
|
||||
if (!fullA || !fullA) return -1;
|
||||
@@ -86,7 +73,6 @@ const listColumns = {
|
||||
},
|
||||
},
|
||||
sso: {
|
||||
label: 'Login',
|
||||
hideMobile: true,
|
||||
sort: (a, b, fullA, fullB) => {
|
||||
if (!fullA || !fullA) return -1;
|
||||
@@ -96,6 +82,17 @@ const listColumns = {
|
||||
|
||||
return checkA - checkB;
|
||||
},
|
||||
width: '30px',
|
||||
},
|
||||
status: {
|
||||
label: 'Status',
|
||||
hideMobile: true,
|
||||
sort: (a, b, fullA, fullB) => {
|
||||
if (!fullA || !fullA) return -1;
|
||||
const checkA = fullA.installationState + '-' + fullA.runState + '-' + fullA.health;
|
||||
const checkB = fullB.installationState + '-' + fullB.runState + '-' + fullB.health;
|
||||
return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
|
||||
},
|
||||
},
|
||||
checklist: {},
|
||||
actions: {}
|
||||
@@ -130,10 +127,6 @@ function onActionMenu(app, event) {
|
||||
visible: !!app.manifest?.addons?.localstorage,
|
||||
target: '_blank',
|
||||
href: '/filemanager.html#/home/app/' + app.id,
|
||||
}, {
|
||||
icon: 'fa-solid fa-cog',
|
||||
label: t('app.configureTooltip'),
|
||||
href: `#/app/${app.id}/info`,
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
@@ -141,21 +134,40 @@ function onActionMenu(app, event) {
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
return apps.value.filter(a => {
|
||||
return a.fqdn.includes(filter.value) || a.id.includes(filter.value);
|
||||
if (a.type === APP_TYPES.LINK) {
|
||||
return a.upstreamUri.includes(filter.value);
|
||||
} else { // app or proxy
|
||||
return a.fqdn.includes(filter.value)
|
||||
|| a.secondaryDomains.some(sd => sd.fqdn.includes(filter.value))
|
||||
|| a.redirectDomains.some(rd => rd.fqdn.includes(filter.value))
|
||||
|| a.aliasDomains.some(ad => ad.fqdn.includes(filter.value))
|
||||
|| a.id.includes(filter.value)
|
||||
|| a.manifest.title.toLocaleLowerCase().includes(filter.value.toLocaleLowerCase());
|
||||
}
|
||||
}).filter(a => {
|
||||
if (!domainFilter.value) return true;
|
||||
return a.domain === domainFilter.value;
|
||||
if (a.type === APP_TYPES.LINK) return false;
|
||||
return a.domain === domainFilter.value
|
||||
|| a.secondaryDomains.some(sd => sd.domain === domainFilter.value)
|
||||
|| a.redirectDomains.some(rd => rd.domain === domainFilter.value)
|
||||
|| a.aliasDomains.some(ad => ad.domain === domainFilter.value);
|
||||
}).filter(a => {
|
||||
if (!tagFilter.value) return true;
|
||||
return a.tags.indexOf(tagFilter.value) !== -1;
|
||||
}).filter(a => {
|
||||
if (!stateFilter.value) return true;
|
||||
if (a.type === APP_TYPES.LINK) return false;
|
||||
|
||||
if (stateFilter.value === 'running') return a.runState === RSTATES.RUNNING && a.health === HSTATES.HEALTHY && a.installationState === ISTATES.INSTALLED;
|
||||
if (stateFilter.value === 'stopped') return a.runState === RSTATES.STOPPED;
|
||||
if (stateFilter.value === 'update_available') return a.updateInfo;
|
||||
|
||||
return a.runState === RSTATES.RUNNING && (a.health !== HSTATES.HEALTHY || a.installationState !== ISTATES.INSTALLED); // not responding
|
||||
}).sort((a, b) => {
|
||||
const labelA = a.label || a.subdomain || a.fqdn;
|
||||
const labelB = b.label || b.subdomain || b.fqdn;
|
||||
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +270,7 @@ onActivated(async () => {
|
||||
const [error, result] = await domainsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
domainFilterOptions.value = domainFilterOptions.value.concat(result.map(d => { d.id = d.domain; return d; }));
|
||||
domainFilterOptions.value = [{ id: '', domain: 'All Domains', }].concat(result.map(d => { d.id = d.domain; return d; }));
|
||||
domainFilter.value = domainFilterOptions.value[0].id;
|
||||
|
||||
stateFilter.value = stateFilterOptions[0].id;
|
||||
@@ -287,9 +299,9 @@ onDeactivated(() => {
|
||||
{{ $t('apps.title') }}
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
|
||||
<TextInput v-model="filter" :placeholder="$t('apps.searchPlaceholder')" />
|
||||
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
|
||||
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
|
||||
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
|
||||
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
|
||||
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
|
||||
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
|
||||
<Button tool outline secondary @click="toggleView()" :icon="viewType === VIEW_TYPE.GRID ? 'fas fa-list' : 'fas fa-grip'"></Button>
|
||||
</div>
|
||||
</h1>
|
||||
@@ -303,7 +315,7 @@ onDeactivated(() => {
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
|
||||
<a v-for="app in filteredApps" :key="app.id" class="grid-item" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank" :style="{ width: itemWidth }">
|
||||
<a v-for="app in filteredApps" :key="app.id" class="grid-item" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank" :style="{ width: itemWidth }">
|
||||
<img :alt="app.label || app.subdomain || app.fqdn" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
<div class="grid-item-label" v-fit-text>{{ app.label || app.subdomain || app.fqdn }}</div>
|
||||
<div class="grid-item-task-label" v-if="app.type === APP_TYPES.LINK">{{ $t('app.appLink.title') }}</div>
|
||||
@@ -324,7 +336,7 @@ onDeactivated(() => {
|
||||
<TableView :columns="listColumns" :model="filteredApps">
|
||||
<template #icon="app">
|
||||
<a :href="'https://' + app.fqdn" target="_blank">
|
||||
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
</a>
|
||||
</template>
|
||||
<template #label="app">
|
||||
@@ -348,6 +360,7 @@ onDeactivated(() => {
|
||||
</template>
|
||||
<template #checklist="app">
|
||||
<a class="list-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" :href="`#/app/${app.id}/info`"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
|
||||
<a class="list-item-update-indicator" v-if="app.updateInfo" @click.stop :href="isOperator(app) ? `#/app/${app.id}/updates` : null" v-tooltip="$t('app.updateAvailableTooltip')"><i class="fa-solid fa-arrow-up"/></a>
|
||||
</template>
|
||||
<template #sso="app">
|
||||
<div v-show="app.type !== APP_TYPES.LINK">
|
||||
@@ -359,6 +372,8 @@ onDeactivated(() => {
|
||||
</template>
|
||||
<template #actions="app">
|
||||
<div style="text-align: right;">
|
||||
<!-- TODO v-tooltip="$t('app.configureTooltip')" but needs pankow fix -->
|
||||
<Button class="action-button" tool plain secondary :href="`#/app/${app.id}/info`" icon="fa-solid fa-cog" />
|
||||
<Button tool plain secondary @click.capture="onActionMenu(app, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -382,6 +397,14 @@ onDeactivated(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.action-button {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
tr:hover .action-button {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.grid-animation-move,
|
||||
.grid-animation-enter-active,
|
||||
.grid-animation-leave-active {
|
||||
@@ -398,6 +421,10 @@ onDeactivated(() => {
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
.item-inactive {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.list-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -543,6 +570,10 @@ onDeactivated(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-item-update-indicator {
|
||||
color: var(--pankow-color-success);
|
||||
}
|
||||
|
||||
.list-item-checklist-indicator {
|
||||
color: var(--pankow-color-danger);
|
||||
}
|
||||
@@ -560,6 +591,8 @@ onDeactivated(() => {
|
||||
}
|
||||
|
||||
.no-matches-placeholder {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ onActivated(async () => {
|
||||
onHashChange();
|
||||
|
||||
window.addEventListener('resize', setItemWidth);
|
||||
|
||||
setTimeout(() => searchInput.value.focus(), 0);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
@@ -223,7 +225,7 @@ onDeactivated(() => {
|
||||
|
||||
<div class="filter-bar">
|
||||
<SingleSelect v-model="category" :options="categories" option-key="id" option-label="label" :disabled="!ready"/>
|
||||
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;"/>
|
||||
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
|
||||
</div>
|
||||
|
||||
<div v-if="!ready" style="margin-top: 15px">
|
||||
@@ -234,12 +236,12 @@ onDeactivated(() => {
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="!search">
|
||||
<h4 v-show="filteredPopularApps.length">{{ $t('appstore.category.popular') }}</h4>
|
||||
<h2 v-show="filteredPopularApps.length">{{ $t('appstore.category.popular') }}</h2>
|
||||
<div class="grid">
|
||||
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredPopularApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
|
||||
</div>
|
||||
|
||||
<h4 v-show="filteredAllApps.length">{{ $t('appstore.category.all') }}</h4>
|
||||
<h2 v-show="filteredAllApps.length">{{ $t('appstore.category.all') }}</h2>
|
||||
<div class="grid">
|
||||
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredAllApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
|
||||
</div>
|
||||
|
||||
@@ -4,30 +4,29 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, reactive } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef, reactive, inject } from 'vue';
|
||||
import { Button, Menu, ProgressBar, InputDialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import Section from '../components/Section.vue';
|
||||
import StateLED from '../components/StateLED.vue';
|
||||
import BackupScheduleDialog from '../components/BackupScheduleDialog.vue';
|
||||
import BackupSiteScheduleDialog from '../components/BackupSiteScheduleDialog.vue';
|
||||
import BackupSiteAddDialog from '../components/BackupSiteAddDialog.vue';
|
||||
import BackupSiteContentDialog from '../components/BackupSiteContentDialog.vue';
|
||||
import BackupSiteConfigDialog from '../components/BackupSiteConfigDialog.vue';
|
||||
import SystemBackupList from '../components/SystemBackupList.vue';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import { cronDays, cronHours, regionName } from '../utils.js';
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
const profile = inject('profile');
|
||||
|
||||
const tasksModel = TasksModel.create();
|
||||
const backupSitesModels = BackupSitesModel.create();
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const systemBackupList = useTemplateRef('systemBackupList');
|
||||
|
||||
const profile = ref({});
|
||||
const sites = ref([]);
|
||||
const busy = ref(true);
|
||||
|
||||
@@ -41,9 +40,9 @@ function onEditContent(site) {
|
||||
backupSiteContentDialog.value.open(site);
|
||||
}
|
||||
|
||||
const backupScheduleDialog = useTemplateRef('backupScheduleDialog');
|
||||
const backupSiteScheduleDialog = useTemplateRef('backupSiteScheduleDialog');
|
||||
function onEditSchedule(site) {
|
||||
backupScheduleDialog.value.open(site);
|
||||
backupSiteScheduleDialog.value.open(site);
|
||||
}
|
||||
|
||||
const backupSiteConfigDialog = useTemplateRef('backupSiteConfigDialog');
|
||||
@@ -66,8 +65,13 @@ function prettyBackupSchedule(pattern) {
|
||||
prettyDay = days.map(function (day) { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(',');
|
||||
}
|
||||
|
||||
const prettyHour = hours.map(function (hour) { return cronHours[parseInt(hour, 10)].name; }).join(',');
|
||||
return prettyDay + ' at ' + prettyHour;
|
||||
let prettyHour;
|
||||
if (hours.length === 24 || hours[0] === '*') {
|
||||
prettyHour = 'hourly';
|
||||
} else {
|
||||
prettyHour = hours.map(function (hour) { return cronHours[parseInt(hour, 10)].name; }).join(',');
|
||||
}
|
||||
return prettyDay + ' @ ' + prettyHour;
|
||||
};
|
||||
|
||||
function prettyBackupRetention(retention) {
|
||||
@@ -113,6 +117,7 @@ async function onRemount(site) {
|
||||
if (statusError) console.error(statusError);
|
||||
|
||||
site.status.state = status.state === 'active' ? 'success' : 'danger';
|
||||
site.status.message = status.message;
|
||||
site.status.busy = false;
|
||||
}
|
||||
|
||||
@@ -156,19 +161,23 @@ const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(site, event) {
|
||||
actionMenuModel.value = [{
|
||||
icon: 'fa-solid fa-screwdriver-wrench',
|
||||
label: t('backups.configAction'),
|
||||
visible: profile.value.isAtLeastOwner,
|
||||
action: onEditConfig.bind(null, site),
|
||||
}, {
|
||||
icon: 'fa-solid fa-box-open',
|
||||
label: t('backups.contentAction'),
|
||||
visible: profile.value.isAtLeastOwner,
|
||||
action: onEditContent.bind(null, site),
|
||||
}, {
|
||||
icon: 'fa-solid fa-clock',
|
||||
label: t('backups.schedule.title'),
|
||||
visible: profile.value.isAtLeastOwner,
|
||||
action: onEditSchedule.bind(null, site),
|
||||
}, {
|
||||
icon: 'fa-solid fa-screwdriver-wrench',
|
||||
label: t('backups.configAction'),
|
||||
action: onEditConfig.bind(null, site),
|
||||
}, {
|
||||
separator: true
|
||||
},{
|
||||
visible: profile.value.isAtLeastOwner,
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-plus',
|
||||
label: t('backups.listing.backupNow'),
|
||||
@@ -183,10 +192,12 @@ function onActionMenu(site, event) {
|
||||
visible: site.provider === 'sshfs' || site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'ext4' || site.provider === 'xfs',
|
||||
action: onRemount.bind(null, site),
|
||||
}, {
|
||||
visible: profile.value.isAtLeastOwner,
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-trash',
|
||||
label: t('volumes.removeVolumeDialog.removeAction'),
|
||||
visible: profile.value.isAtLeastOwner,
|
||||
action: onRemoveSite.bind(null, site),
|
||||
}];
|
||||
|
||||
@@ -212,6 +223,29 @@ async function onCancelTask(taskId) {
|
||||
if (error) console.error('Failed to cancel task:', error);
|
||||
}
|
||||
|
||||
async function refreshStatusForSite(site) {
|
||||
const [error, status] = await backupSitesModels.status(site.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
site.status.state = status.state === 'active' ? 'success' : 'danger';
|
||||
site.status.message = status.message;
|
||||
site.status.busy = false;
|
||||
}
|
||||
|
||||
async function refreshTaskForSite(site) {
|
||||
const [error, tasks] = await tasksModel.getByType(TASK_TYPES.TASK_FULL_BACKUP_PREFIX + site.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
if (tasks[0]) {
|
||||
site.task = tasks[0];
|
||||
if (site.task.active) setTimeout(waitForSiteTask.bind(null, site), 2000);
|
||||
} else {
|
||||
site.task = null;
|
||||
}
|
||||
|
||||
site.taskLoaded = true;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
busy.value = true;
|
||||
|
||||
@@ -225,25 +259,12 @@ async function refresh() {
|
||||
// have to make it a reactive object as we manipulate property objects
|
||||
const site = reactive(result);
|
||||
site.status = { busy: true, state: '', message: '' };
|
||||
site.task = null;
|
||||
site.taskLoaded = false;
|
||||
|
||||
const [error, status] = await backupSitesModels.status(site.id);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
site.status.state = status.state === 'active' ? 'success' : 'danger';
|
||||
site.status.busy = false;
|
||||
|
||||
const [taskError, tasks] = await tasksModel.getByType(TASK_TYPES.TASK_FULL_BACKUP_PREFIX + site.id);
|
||||
if (taskError) {
|
||||
console.error(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
site.task = tasks[0] || null;
|
||||
|
||||
if (site.task && site.task.active) setTimeout(waitForSiteTask.bind(null, site), 2000);
|
||||
// do not wait for it
|
||||
refreshStatusForSite(site);
|
||||
refreshTaskForSite(site);
|
||||
|
||||
sitesWithDetails.push(site);
|
||||
}
|
||||
@@ -253,11 +274,6 @@ async function refresh() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
|
||||
profile.value = result;
|
||||
|
||||
await refresh();
|
||||
});
|
||||
|
||||
@@ -270,19 +286,19 @@ onMounted(async () => {
|
||||
<BackupSiteAddDialog ref="backupSiteAddDialog" @success="refresh()"/>
|
||||
<BackupSiteContentDialog ref="backupSiteContentDialog" @success="refresh()"/>
|
||||
<BackupSiteConfigDialog ref="backupSiteConfigDialog" @success="refresh()"/>
|
||||
<BackupScheduleDialog ref="backupScheduleDialog" @success="refresh()"/>
|
||||
<BackupSiteScheduleDialog ref="backupSiteScheduleDialog" @success="refresh()"/>
|
||||
|
||||
<Section :title="$t('backup.sites.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onAdd()"> {{ $t('main.action.add') }}</Button>
|
||||
<Button v-if="profile.isAtLeastOwner" @click="onAdd()"> {{ $t('main.action.add') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
|
||||
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
|
||||
<div class="backup-site" v-for="site in sites" :key="site.id">
|
||||
<div style="display: flex; align-items: start;">
|
||||
<StateLED style="padding-top: 5px;" :busy="site.status.busy" :state="site.status.state"/>
|
||||
<div style="display: flex; align-items: start; margin-top: 6px;">
|
||||
<StateLED :busy="site.status.busy" :state="site.status.state"/>
|
||||
</div>
|
||||
<div class="backup-site-details">
|
||||
<div style="margin-bottom: 5px; display: flex; justify-content: space-between; align-items: baseline;">
|
||||
@@ -316,7 +332,11 @@ onMounted(async () => {
|
||||
{{ $t('backups.schedule.retentionPolicy') }}: <b>{{ prettyBackupRetention(site.retention) }}</b>
|
||||
</div>
|
||||
<div class="backup-site-task">
|
||||
<div v-if="!site.task">{{ $t('backup.sites.lastRun') }}: <b>Never</b></div>
|
||||
<div v-if="!site.task">
|
||||
{{ $t('backup.sites.lastRun') }}:
|
||||
<b v-if="site.taskLoaded">Never</b>
|
||||
<span v-else>...</span>
|
||||
</div>
|
||||
<div v-if="site.task && site.task.success">{{ $t('backup.sites.lastRun') }}: <b>{{ prettyLongDate(site.task.ts) }}</b></div>
|
||||
<div v-if="site.task && site.task.error">
|
||||
{{ $t('backup.sites.lastRun') }}: <b>{{ prettyLongDate(site.task.ts) }}</b>
|
||||
@@ -324,6 +344,7 @@ onMounted(async () => {
|
||||
<a :href="`/logs.html?taskId=${site.task.id}`" target="_blank"><span class="error-label">{{ site.task.error.message }} <Button small plain tool>Logs</Button></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;" class="text-danger" v-if="site.status.message" v-html="site.status.message"></div>
|
||||
<div v-if="site.task && site.task.running">
|
||||
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
|
||||
<div style="flex-grow: 1; overflow: hidden;">
|
||||
|
||||
@@ -42,10 +42,12 @@ const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
async function onRemove(domain) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('domains.removeDialog.title', { domain: domain.domain }),
|
||||
title: t('domains.removeDialog.title'),
|
||||
message: t('domains.removeDialog.description', { domain: domain.domain }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('domains.removeDialog.removeAction'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -93,7 +95,7 @@ function onActionMenu(domain, event) {
|
||||
}, {
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
disabled: domain.domain.value === dashboardDomain.value,
|
||||
disabled: domain.domain === dashboardDomain.value,
|
||||
action: onRemove.bind(null, domain),
|
||||
}];
|
||||
|
||||
@@ -141,16 +143,18 @@ onMounted(async () => {
|
||||
<template #header-title-extra>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ busy ? '-' : filteredDomains.length }})</span>
|
||||
</template>
|
||||
<template #filter-bar>
|
||||
<TextInput v-model="search" :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;"/>
|
||||
</template>
|
||||
<template #header-buttons>
|
||||
<TextInput v-model="search" :placeholder="$t('main.searchPlaceholder')"/>
|
||||
<Button @click="onAdd()">{{ $t('main.action.add') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('domains.domainDialog.addDescription') }}</div>
|
||||
<div>{{ $t('domains.description') }}</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<TableView :model="filteredDomains" :columns="columns" :busy="busy" style="max-height: 200px;" :placeholder="$t(search ? 'domains.noMatchesPlaceholder' : 'domains.emptyPlaceholder')">
|
||||
<TableView :model="filteredDomains" :columns="columns" :busy="busy" style="max-height: 450px;" :placeholder="$t(search ? 'domains.noMatchesPlaceholder' : 'domains.emptyPlaceholder')">
|
||||
<template #provider="domain">
|
||||
{{ DomainsModel.prettyProviderName(domain.provider) }}
|
||||
</template>
|
||||
@@ -166,4 +170,4 @@ onMounted(async () => {
|
||||
<SyncDns />
|
||||
<DashboardDomain ref="dashboardDomainComponent"/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -112,20 +112,20 @@ async function onEnableIncoming() {
|
||||
}
|
||||
|
||||
|
||||
const mailFromValidation = ref(false);
|
||||
const mailFromValidationBusy = ref(false);
|
||||
const customFrom = ref(false);
|
||||
const customFromBusy = ref(false);
|
||||
|
||||
async function onToggleMailFromValidation(value) {
|
||||
mailFromValidationBusy.value = true;
|
||||
async function onToggleCustomFrom(value) {
|
||||
customFromBusy.value = true;
|
||||
|
||||
const [error] = await mailModel.setMailFromValidation(domain.value, value);
|
||||
const [error] = await mailModel.setMailFromValidation(domain.value, !value); // note: inverted logic between UI switch and API
|
||||
if (error) {
|
||||
mailFromValidation.value = !value;
|
||||
mailFromValidationBusy.value = false;
|
||||
customFrom.value = !value; // revert back old value
|
||||
customFromBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
mailFromValidationBusy.value = false;
|
||||
customFromBusy.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ async function onDomainChanged() {
|
||||
mailConfig.value = result;
|
||||
inboundEnabled.value = result.enabled;
|
||||
outboundEnabled.value = result.relay?.provider !== 'noop';
|
||||
mailFromValidation.value = result.mailFromValidation;
|
||||
customFrom.value = !result.mailFromValidation; // note: inverted logic between UI switch and API
|
||||
signatureText.value = mailConfig.value.banner.text || '';
|
||||
signatureHtml.value = mailConfig.value.banner.html || '';
|
||||
|
||||
@@ -289,10 +289,10 @@ onMounted(async () => {
|
||||
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.masquerading.title') }}</label>
|
||||
<div v-html="$t('email.masquerading.description')"></div>
|
||||
<label>{{ $t('email.customFrom.title') }}</label>
|
||||
<div v-html="$t('email.customFrom.description')"></div>
|
||||
</FormGroup>
|
||||
<Switch v-model="mailFromValidation" @change="onToggleMailFromValidation" :disabled="mailFromValidationBusy"/>
|
||||
<Switch v-model="customFrom" @change="onToggleCustomFrom" :disabled="customFromBusy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
|
||||
@@ -13,6 +13,7 @@ const domainsModel = DomainsModel.create();
|
||||
const mailModel = MailModel.create();
|
||||
|
||||
const domains = ref([]);
|
||||
const busy = ref(true);
|
||||
|
||||
const searchFilter = ref('');
|
||||
|
||||
@@ -131,10 +132,10 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="domain.inboundEnabled">
|
||||
Outbound (via {{ prettyRelayProviderName(domain.relayProvider) }}) - {{ $t('emails.domains.stats', { mailboxCount: domain.mailboxCount, usage: prettyDecimalSize(domain.usage) }) }}
|
||||
{{ $t('emails.domains.inbound') }}. Relayed via {{ prettyRelayProviderName(domain.relayProvider) }}. {{ $t('emails.domains.stats', { mailboxCount: domain.mailboxCount, usage: prettyDecimalSize(domain.usage) }) }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<span v-if="domain.outboundEnabled">{{ $t('emails.domains.outbound') }} (via {{ prettyRelayProviderName(domain.relayProvider) }})</span>
|
||||
<span v-if="domain.outboundEnabled">{{ $t('emails.domains.outbound') }}. Relayed via {{ prettyRelayProviderName(domain.relayProvider) }}</span>
|
||||
<span v-else>{{ $t('emails.domains.disabled') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,17 +76,17 @@ onMounted(async () => {
|
||||
<table class="eventlog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%"><!-- Icon --></th>
|
||||
<th style="width: 14%">{{ $t('emails.eventlog.time') }}</th>
|
||||
<th style="width: 26%">{{ $t('emails.eventlog.mailFrom') }}</th>
|
||||
<th style="width: 25%">{{ $t('emails.eventlog.rcptTo') }}</th>
|
||||
<th style="width: 30%">{{ $t('emails.eventlog.details') }}</th>
|
||||
<th style="width: 25px"><!-- Icon --></th>
|
||||
<th style="width:160px">{{ $t('emails.eventlog.time') }}</th>
|
||||
<th style="width: 20%">{{ $t('emails.eventlog.mailFrom') }}</th>
|
||||
<th style="width: 20%">{{ $t('emails.eventlog.rcptTo') }}</th>
|
||||
<th>{{ $t('emails.eventlog.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="eventlog in eventlogs" :key="eventlog._id">
|
||||
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
|
||||
<td class="no-wrap">
|
||||
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
|
||||
<td>
|
||||
<i class="fas fa-arrow-circle-left" v-if="eventlog.type === 'sent'" v-tooltip="$t('emails.eventlog.type.outgoing')"></i>
|
||||
<i class="fas fa-history" v-if="eventlog.type === 'deferred'" v-tooltip="$t('emails.eventlog.type.deferred')"></i>
|
||||
<i class="fas fa-arrow-circle-right" v-if="eventlog.type === 'saved'" v-tooltip="$t('emails.eventlog.type.incoming')"></i>
|
||||
@@ -98,7 +98,7 @@ onMounted(async () => {
|
||||
<i class="fas fa-filter" v-if="eventlog.type === 'spam-learn'" v-tooltip="$t('emails.eventlog.type.spamFilterTrained')"></i>
|
||||
<i class="fas fa-fill-drip" v-if="eventlog.type === 'quota'" v-tooltip="$t('emails.eventlog.type.quota')"></i>
|
||||
</td>
|
||||
<td class="no-wrap">{{ prettyLongDate(eventlog.ts) }}</td>
|
||||
<td>{{ prettyLongDate(eventlog.ts) }}</td>
|
||||
<td class="elide-table-cell">{{ prettyEmailAddresses(eventlog.mailFrom) || '-' }}</td>
|
||||
<td class="elide-table-cell">{{ prettyEmailAddresses(eventlog.rcptTo) || eventlog.mailbox || '-' }}</td>
|
||||
<td>
|
||||
@@ -119,7 +119,7 @@ onMounted(async () => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="eventlog.isOpen">
|
||||
<td colspan="6" class="eventlog-details">
|
||||
<td colspan="5" class="eventlog-details">
|
||||
<pre>{{ JSON.stringify(eventlog, null, 4) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -39,10 +39,14 @@ const columns = {
|
||||
},
|
||||
usage: {
|
||||
label: t('email.incoming.mailboxes.usage'),
|
||||
sort: true,
|
||||
sort: (a, b) => {
|
||||
if (!a.diskSize) return -1;
|
||||
if (!b.diskSize) return 1;
|
||||
return a.diskSize - b.diskSize;
|
||||
},
|
||||
hideMobile: true,
|
||||
},
|
||||
quota: {
|
||||
storageQuota: {
|
||||
label: 'Quota',
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
@@ -208,7 +212,7 @@ onMounted(async () => {
|
||||
<div class="content">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<Dialog ref="removeDialog"
|
||||
:title="$t('email.deleteMailboxDialog.title', { name: removeMailbox.name, domain: removeMailbox.domain })"
|
||||
:title="$t('email.deleteMailboxDialog.title')"
|
||||
:confirm-label="$t('email.deleteMailboxDialog.deleteAction')"
|
||||
:confirm-busy="removeBusy"
|
||||
:confirm-active="!removeBusy"
|
||||
@@ -220,7 +224,7 @@ onMounted(async () => {
|
||||
>
|
||||
<div>
|
||||
<div class="text-danger" v-if="removeError">{{ removeError }}</div>
|
||||
<div v-html="$t('email.deleteMailboxDialog.description')"></div>
|
||||
<div v-html="$t('email.deleteMailboxDialog.description', { name: removeMailbox.name, domain: removeMailbox.domain })"></div>
|
||||
<br/>
|
||||
<Checkbox v-model="removePurge" :label="$t('email.deleteMailboxDialog.purgeMailboxCheckbox')" />
|
||||
</div>
|
||||
@@ -230,7 +234,7 @@ onMounted(async () => {
|
||||
|
||||
<Section :title="$t('email.incoming.mailboxes.title')">
|
||||
<template #header-title-extra>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ $t('emails.domains.stats', { mailboxCount: filteredMailboxes.length, usage: prettyDecimalSize(filteredMailboxesUsage) }) }})</span>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ $t('email.incoming.mailboxes.stats', { mailboxCount: filteredMailboxes.length, usage: prettyDecimalSize(filteredMailboxesUsage) }) }})</span>
|
||||
</template>
|
||||
<template #header-buttons>
|
||||
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="searchFilter"/>
|
||||
@@ -243,7 +247,7 @@ onMounted(async () => {
|
||||
<span v-if="mailbox.usage || mailbox.usage === 0">{{ prettyDecimalSize(mailbox.usage.diskSize) }}</span>
|
||||
<span v-else>{{ $t('main.loadingPlaceholder') }} ...</span>
|
||||
</template>
|
||||
<template #quota="mailbox">
|
||||
<template #storageQuota="mailbox">
|
||||
<span v-if="mailbox.usage && mailbox.usage.quotaLimit">{{ prettyDecimalSize(mailbox.usage.quotaLimit) }}</span>
|
||||
</template>
|
||||
<template #actions="mailbox">
|
||||
|
||||
@@ -142,7 +142,7 @@ onMounted(async () => {
|
||||
<div class="content">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<Dialog ref="removeDialog"
|
||||
:title="$t('email.deleteMailinglistDialog.title', { name: removeMailinglist.name, domain: removeMailinglist.domain })"
|
||||
:title="$t('email.deleteMailinglistDialog.title')"
|
||||
:confirm-label="$t('email.deleteMailinglistDialog.deleteAction')"
|
||||
:confirm-busy="removeBusy"
|
||||
:confirm-active="!removeBusy"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user