Compare commits

..

1 Commits

Author SHA1 Message Date
Girish Ramakrishnan 0a91e6b2c0 scheduler: do not create jobs of suspended apps
otherwise, when an app is uninstalling, it creates the docker containers
by calling getDynamicEnvironment. This ends up adding addonConfigs for the
docker addon and prevents the app from getting uninstalled.
2024-03-11 23:34:08 +01:00
258 changed files with 15238 additions and 9831 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"env": {
"node": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 13
},
"rules": {
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-console": "off"
}
}
-24
View File
@@ -1,24 +0,0 @@
run_tests:
stage: test
image: cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4
services:
- name: mysql:8.0
alias: mysql
variables:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: box
BOX_ENV: ci
DATABASE_URL: mysql://root:password@mysql/box
script:
- echo "Running tests..."
- mysql -hmysql -uroot -ppassword -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password';"
- mysql -hmysql -uroot -ppassword -e "CREATE DATABASE IF NOT EXISTS box"
- npm install
- node_modules/.bin/db-migrate up
- ln -s /usr/local/node-18.18.0/bin/node /usr/bin/node
- node_modules/.bin/mocha --no-timeouts --bail src/test/tokens-test.js
- echo "Done!"
stages:
- test
+5
View File
@@ -0,0 +1,5 @@
{
"node": true,
"unused": true,
"esversion": 11
}
-98
View File
@@ -2753,101 +2753,3 @@
* Fix streaming of logs with `logPaths`
* profile: store user language setting in the database
[7.7.1]
* postgresql: fix bug in loading of contrib extensions
* dashboard: use native slider element for app memory and cpu
[7.7.2]
* docker: use unix domain socket based logging instead of udp
* dashboard: use native slider element for app memory and cpu
* filemanager: fix empty folder content layout
* dashboard: preserve app link paths
* backups: deleted apps must also be displayed in contents
* filemanager: make uploads cancellable
* Fix crash on systemds with no swap
[8.0.0]
* mongodb: optionally start mongodb based on AVX support
* dashboard: font and color improvements
* docker: prune volumes on infra change
* oidc: initial login of admin and normal user now gets an OIDC session
* branding: default background image for the dashboard
* dashboard: list view for apps
* import: fix crash when using mountpoint provider
* dashboard: set '/' as keyboard shortcut
* app: memory limit is redefined to be just RAM and unlimited swap
* dashboard: rework filter UI
* cpu: rework cpu shares into cpu quota
* cifs: enable seal encryption by default
* updatechecker: fix bug where release info was not refreshed
* ovh: storage location domain has changed. add rbx region
* domains: add deSEC integration
* notfound: better message when navigating by IP address
* IPv6 only server installation
* Initial Ubuntu 24.04 (Noble Numbat) support
* syslog: handle potential multiline syslog input
* user directory: fixes to mandatory 2fa setting when cloudron connector is used
* notification: do not send login notification for external users
* dashboard: pending checklist indicator
* cloudron-support: add --recreate-docker and --recreate-container
* filemanager: add dark mode
* proxyauth: now uses oidc instead of ldap auth
* dashboard: add admin notes
* Use systemd-resolved as the system resolver. unbound is now only for mail server and recursive DNS lookups
[8.0.1]
* nfs: disable rpcbind service. we only support nfsv4 mounting
* dashboard: only show postinstall if notes are not just empty
* ami: disable route53
* mailer: add html version of test mail
* sshfs: server side copying
* backups: rewrite tgz backups using tar-stream
* backups: fix issue with s3 backend where files missing in remote was not detected correctly
* provision: redirect to correct task (setup/restore/activation)
[8.0.2]
* tgz: fix unhandled promise error handler
* tgz: add underflow/overflow proxy stream to ensure size of a changing file
* backups: give task a low oomScoreAdjust to not get killed
* Fix issue with uploads via File Manager where temp files were not cleaned up
* addons: fix crash when importing database of an app with no addons
* sshfs: if remote copy fails, fallback to sshfs based copy
* frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines
[8.0.3]
* logs: fix recursion when displaying box logs
* frontend: fix clear view in logs viewer
* dashboard: support links/markdown in checklist items
[8.0.4]
* ami: IMDv2 support
* ionos: add contract-owned eu-central-3
* dashboard: remove mailbox import/export feature
* backupcleaner: do not remove the backup in progress
* backups: make noop upload work again
* volumes: `/mnt/volumes` is reserved
* apps: do not log app logs to output
* sftp: restore mode and owner
* dashboard: also render checklist items in apps.html
[8.0.5]
* cpu quota: fix rounding error
* frontend: fix translation resolver to actually fallback to english
* i18n: fix crash if language file is missing
* memory: fix slider UI where max was incorrectly set
* digitalocean: add LON1 Spaces region
* exoscale: add sos AT-VIE-2 region
* i18n: remove use of "Cloudron"
* tz: add note in backup and update UI
* backups: do not overflow the schedule timings
* checklist: new checklist items on update are acknowledged
* backups: automatically trigger a remount if mount is not active
* logs: rework the syslog parser
* docker: use system dns for app containers
* logs: show error message in UI when log rotated
* unbound: prefer ip4 for dns queries (only on ubuntu 24 and above)
* apps: allow operators to update apps
[8.0.6]
* Fix AdGuard resolving dashboard to docker bridge IP
+25 -22
View File
@@ -3,7 +3,9 @@
'use strict';
const argv = require('yargs').argv,
autoprefixer = require('gulp-autoprefixer'),
concat = require('gulp-concat'),
cssnano = require('gulp-cssnano'),
ejs = require('gulp-ejs'),
execSync = require('child_process').execSync,
fs = require('fs'),
@@ -46,11 +48,6 @@ gulp.task('fontawesome', function () {
.pipe(gulp.dest('dist/3rdparty/fontawesome/'));
});
gulp.task('noto-sans', function () {
return gulp.src('node_modules/@fontsource/noto-sans/**/*')
.pipe(gulp.dest('dist/3rdparty/noto-sans/'));
});
gulp.task('bootstrap', function () {
return gulp.src('node_modules/bootstrap-sass/assets/javascripts/bootstrap.min.js')
.pipe(gulp.dest('dist/3rdparty/js'));
@@ -75,7 +72,7 @@ gulp.task('3rdparty-copy', function () {
]).pipe(gulp.dest('dist/3rdparty/'));
});
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'moment', 'bootstrap', 'fontawesome', 'noto-sans']));
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'moment', 'bootstrap', 'fontawesome']));
// --------------
// JavaScript
@@ -113,15 +110,6 @@ gulp.task('js-setupaccount', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-activation', function () {
return gulp.src(['src/js/activation.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('activation.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-setup', function () {
return gulp.src(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
@@ -131,6 +119,15 @@ gulp.task('js-setup', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-setupdns', function () {
return gulp.src(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('setupdns.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-restore', function () {
return gulp.src(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
@@ -140,7 +137,7 @@ gulp.task('js-restore', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js', gulp.series([ 'js-index', 'js-passwordreset', 'js-setupaccount', 'js-activation', 'js-setup', 'js-restore' ]));
gulp.task('js', gulp.series([ 'js-index', 'js-passwordreset', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
// --------------
// HTML
@@ -150,11 +147,15 @@ gulp.task('html-views', function () {
return gulp.src('src/views/**/*.html').pipe(gulp.dest('dist/views'));
});
gulp.task('html-templates', function () {
return gulp.src('src/templates/**/*').pipe(gulp.dest('dist/templates'));
});
gulp.task('html-raw', function () {
return gulp.src('src/*.html').pipe(ejs({ apiOrigin: apiOrigin, revision: revision }, {}, { ext: '.html' })).pipe(gulp.dest('dist'));
});
gulp.task('html', gulp.series(['html-views', 'html-raw']));
gulp.task('html', gulp.series(['html-views', 'html-templates', 'html-raw']));
// --------------
// CSS
@@ -162,10 +163,11 @@ gulp.task('html', gulp.series(['html-views', 'html-raw']));
gulp.task('css', function () {
return gulp.src('src/*.scss')
.pipe(sass({ includePaths: [
'node_modules/bootstrap-sass/assets/stylesheets/',
'node_modules/@fontsource/'
]}).on('error', sass.logError))
.pipe(sourcemaps.init())
.pipe(sass({ includePaths: ['node_modules/bootstrap-sass/assets/stylesheets/'] }).on('error', sass.logError))
.pipe(autoprefixer())
.pipe(cssnano())
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist'));
});
@@ -200,9 +202,10 @@ gulp.task('watch', function (done) {
gulp.watch(['src/translation/*'], gulp.series(['translation']));
gulp.watch(['src/**/*.html'], gulp.series(['html']));
gulp.watch(['src/views/*.html'], gulp.series(['html-views']));
gulp.watch(['src/templates/*.html'], gulp.series(['html-templates']));
gulp.watch(['scripts/createTimezones.js', 'src/js/utils.js'], gulp.series(['timezones']));
gulp.watch(['src/js/activation.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-activation']));
gulp.watch(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setup']));
gulp.watch(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setupdns']));
gulp.watch(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-restore']));
gulp.watch(['src/js/passwordreset.js', 'src/js/utils.js'], gulp.series(['js-passwordreset']));
gulp.watch(['src/js/setupaccount.js', 'src/js/utils.js'], gulp.series(['js-setupaccount']));
+3425 -40
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -13,18 +13,19 @@
"author": "",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@fontsource/noto-sans": "^5.0.21",
"@fortawesome/fontawesome-free": "^6.5.2",
"@fortawesome/fontawesome-free": "^6.4.0",
"bootstrap-sass": "^3.4.3",
"chart.js": "^4.4.2",
"chart.js": "^4.3.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-cssnano": "^2.1.3",
"gulp-ejs": "^5.1.0",
"gulp-sass": "^5.1.0",
"gulp-serve": "^1.4.0",
"gulp-sourcemaps": "^3.0.0",
"moment": "^2.30.1",
"sass": "^1.75.0",
"moment": "^2.29.4",
"sass": "^1.63.3",
"yargs": "^17.7.2"
},
"eslintConfig": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,255 @@
/*! =======================================================
VERSION 6.0.12
========================================================= */
/*! =========================================================
* bootstrap-slider.js
*
* Maintainers:
* Kyle Kemp
* - Twitter: @seiyria
* - Github: seiyria
* Rohit Kalkur
* - Twitter: @Rovolutionary
* - Github: rovolution
*
* =========================================================
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================= */
.slider {
display: inline-block;
vertical-align: middle;
position: relative;
}
.slider.slider-horizontal {
width: 210px;
height: 20px;
}
.slider.slider-horizontal .slider-track {
height: 10px;
width: 100%;
margin-top: -5px;
top: 50%;
left: 0;
}
.slider.slider-horizontal .slider-selection,
.slider.slider-horizontal .slider-track-low,
.slider.slider-horizontal .slider-track-high {
height: 100%;
top: 0;
bottom: 0;
}
.slider.slider-horizontal .slider-tick,
.slider.slider-horizontal .slider-handle {
margin-left: -10px;
margin-top: -5px;
}
.slider.slider-horizontal .slider-tick.triangle,
.slider.slider-horizontal .slider-handle.triangle {
border-width: 0 10px 10px 10px;
width: 0;
height: 0;
border-bottom-color: #0480be;
margin-top: 0;
}
.slider.slider-horizontal .slider-tick-label-container {
white-space: nowrap;
margin-top: 20px;
}
.slider.slider-horizontal .slider-tick-label-container .slider-tick-label {
padding-top: 4px;
display: inline-block;
text-align: center;
}
.slider.slider-vertical {
height: 210px;
width: 20px;
}
.slider.slider-vertical .slider-track {
width: 10px;
height: 100%;
margin-left: -5px;
left: 50%;
top: 0;
}
.slider.slider-vertical .slider-selection {
width: 100%;
left: 0;
top: 0;
bottom: 0;
}
.slider.slider-vertical .slider-track-low,
.slider.slider-vertical .slider-track-high {
width: 100%;
left: 0;
right: 0;
}
.slider.slider-vertical .slider-tick,
.slider.slider-vertical .slider-handle {
margin-left: -5px;
margin-top: -10px;
}
.slider.slider-vertical .slider-tick.triangle,
.slider.slider-vertical .slider-handle.triangle {
border-width: 10px 0 10px 10px;
width: 1px;
height: 1px;
border-left-color: #0480be;
margin-left: 0;
}
.slider.slider-vertical .slider-tick-label-container {
white-space: nowrap;
}
.slider.slider-vertical .slider-tick-label-container .slider-tick-label {
padding-left: 4px;
}
.slider.slider-disabled .slider-handle {
background-image: -webkit-linear-gradient(top, #dfdfdf 0%, #bebebe 100%);
background-image: -o-linear-gradient(top, #dfdfdf 0%, #bebebe 100%);
background-image: linear-gradient(to bottom, #dfdfdf 0%, #bebebe 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdfdfdf', endColorstr='#ffbebebe', GradientType=0);
}
.slider.slider-disabled .slider-track {
background-image: -webkit-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%);
background-image: -o-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%);
background-image: linear-gradient(to bottom, #e5e5e5 0%, #e9e9e9 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe5e5e5', endColorstr='#ffe9e9e9', GradientType=0);
cursor: not-allowed;
}
.slider input {
display: none;
}
.slider .tooltip.top {
margin-top: -36px;
}
.slider .tooltip-inner {
white-space: nowrap;
}
.slider .hide {
display: none;
}
.slider-track {
position: absolute;
cursor: pointer;
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%);
background-image: -o-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%);
background-image: linear-gradient(to bottom, #f5f5f5 0%, #f9f9f9 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff9f9f9', GradientType=0);
-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.slider-selection {
position: absolute;
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0);
-webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-radius: 4px;
}
.slider-selection.tick-slider-selection {
background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0);
}
.slider-track-low,
.slider-track-high {
position: absolute;
background: transparent;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-radius: 4px;
}
.slider-handle {
position: absolute;
width: 20px;
height: 20px;
background-color: #337ab7;
background-image: -webkit-linear-gradient(top, #149bdf 0%, #0480be 100%);
background-image: -o-linear-gradient(top, #149bdf 0%, #0480be 100%);
background-image: linear-gradient(to bottom, #149bdf 0%, #0480be 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf', endColorstr='#ff0480be', GradientType=0);
filter: none;
-webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05);
box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05);
border: 0px solid transparent;
}
.slider-handle.round {
border-radius: 50%;
}
.slider-handle.triangle {
background: transparent none;
}
.slider-handle.custom {
background: transparent none;
}
.slider-handle.custom::before {
line-height: 20px;
font-size: 20px;
content: '\2605';
color: #726204;
}
.slider-tick {
position: absolute;
width: 20px;
height: 20px;
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0);
-webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
filter: none;
opacity: 0.8;
border: 0px solid transparent;
}
.slider-tick.round {
border-radius: 50%;
}
.slider-tick.triangle {
background: transparent none;
}
.slider-tick.custom {
background: transparent none;
}
.slider-tick.custom::before {
line-height: 20px;
font-size: 20px;
content: '\2605';
color: #726204;
}
.slider-tick.in-selection {
background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0);
opacity: 1;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+232
View File
@@ -0,0 +1,232 @@
(function(factory) {
if (typeof define === 'function' && define.amd) {
define(['angular', 'bootstrap-slider'], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('angular'), require('bootstrap-slider'));
} else if (window) {
factory(window.angular, window.Slider);
}
})(function (angular, Slider) {
angular.module('ui.bootstrap-slider', [])
.directive('slider', ['$parse', '$timeout', '$rootScope', function ($parse, $timeout, $rootScope) {
return {
restrict: 'AE',
replace: true,
template: '<div><input class="slider-input" type="text" style="width:100%" /></div>',
require: 'ngModel',
scope: {
max: "=",
min: "=",
step: "=",
value: "=",
ngModel: '=',
ngDisabled: '=',
range: '=',
sliderid: '=',
ticks: '=',
ticksLabels: '=',
ticksSnapBounds: '=',
ticksPositions: '=',
scale: '=',
focus: '=',
formatter: '&',
onStartSlide: '&',
onStopSlide: '&',
onSlide: '&'
},
link: function ($scope, element, attrs, ngModelCtrl, $compile) {
var ngModelDeregisterFn, ngDisabledDeregisterFn;
var slider = initSlider();
function initSlider() {
var options = {};
function setOption(key, value, defaultValue) {
options[key] = value || defaultValue;
}
function setFloatOption(key, value, defaultValue) {
options[key] = value || value === 0 ? parseFloat(value) : defaultValue;
}
function setBooleanOption(key, value, defaultValue) {
options[key] = value ? value + '' === 'true' : defaultValue;
}
function getArrayOrValue(value) {
return (angular.isString(value) && value.indexOf("[") === 0) ? angular.fromJson(value) : value;
}
setOption('id', $scope.sliderid);
setOption('orientation', attrs.orientation, 'horizontal');
setOption('selection', attrs.selection, 'before');
setOption('handle', attrs.handle, 'round');
setOption('tooltip', attrs.sliderTooltip || attrs.tooltip, 'show');
setOption('tooltip_position', attrs.sliderTooltipPosition, 'top');
setOption('tooltipseparator', attrs.tooltipseparator, ':');
setOption('ticks', $scope.ticks);
setOption('ticks_labels', $scope.ticksLabels);
setOption('ticks_snap_bounds', $scope.ticksSnapBounds);
setOption('ticks_positions', $scope.ticksPositions);
setOption('scale', $scope.scale, 'linear');
setOption('focus', $scope.focus);
setFloatOption('min', $scope.min, 0);
setFloatOption('max', $scope.max, 10);
setFloatOption('step', $scope.step, 1);
var strNbr = options.step + '';
var dotPos = strNbr.search(/[^.,]*$/);
var decimals = strNbr.substring(dotPos);
setFloatOption('precision', attrs.precision, decimals.length);
setBooleanOption('tooltip_split', attrs.tooltipsplit, false);
setBooleanOption('enabled', attrs.enabled, true);
setBooleanOption('naturalarrowkeys', attrs.naturalarrowkeys, false);
setBooleanOption('reversed', attrs.reversed, false);
setBooleanOption('range', $scope.range, false);
if (options.range) {
if (angular.isArray($scope.value)) {
options.value = $scope.value;
}
else if (angular.isString($scope.value)) {
options.value = getArrayOrValue($scope.value);
if (!angular.isArray(options.value)) {
var value = parseFloat($scope.value);
if (isNaN(value)) value = 5;
if (value < $scope.min) {
value = $scope.min;
options.value = [value, options.max];
}
else if (value > $scope.max) {
value = $scope.max;
options.value = [options.min, value];
}
else {
options.value = [options.min, options.max];
}
}
}
else {
options.value = [options.min, options.max]; // This is needed, because of value defined at $.fn.slider.defaults - default value 5 prevents creating range slider
}
$scope.ngModel = options.value; // needed, otherwise turns value into [null, ##]
}
else {
setFloatOption('value', $scope.value, 5);
}
if (attrs.formatter) {
options.formatter = function(value) {
return $scope.formatter({value: value});
}
}
// check if slider jQuery plugin exists
if ('$' in window && $.fn.slider) {
// adding methods to jQuery slider plugin prototype
$.fn.slider.constructor.prototype.disable = function () {
this.picker.off();
};
$.fn.slider.constructor.prototype.enable = function () {
this.picker.on();
};
}
// destroy previous slider to reset all options
if (element[0].__slider)
element[0].__slider.destroy();
var slider = new Slider(element[0].getElementsByClassName('slider-input')[0], options);
element[0].__slider = slider;
// everything that needs slider element
var updateEvent = getArrayOrValue(attrs.updateevent);
if (angular.isString(updateEvent)) {
// if only single event name in string
updateEvent = [updateEvent];
}
else {
// default to slide event
updateEvent = ['slide'];
}
angular.forEach(updateEvent, function (sliderEvent) {
slider.on(sliderEvent, function (ev) {
ngModelCtrl.$setViewValue(ev);
});
});
slider.on('change', function (ev) {
ngModelCtrl.$setViewValue(ev.newValue);
});
// Event listeners
var sliderEvents = {
slideStart: 'onStartSlide',
slide: 'onSlide',
slideStop: 'onStopSlide'
};
angular.forEach(sliderEvents, function (sliderEventAttr, sliderEvent) {
var fn = $parse(attrs[sliderEventAttr]);
slider.on(sliderEvent, function (ev) {
if ($scope[sliderEventAttr]) {
$scope.$apply(function () {
fn($scope.$parent, { $event: ev, value: ev });
});
}
});
});
// deregister ngDisabled watcher to prevent memory leaks
if (angular.isFunction(ngDisabledDeregisterFn)) {
ngDisabledDeregisterFn();
ngDisabledDeregisterFn = null;
}
ngDisabledDeregisterFn = $scope.$watch('ngDisabled', function (value) {
if (value) {
slider.disable();
}
else {
slider.enable();
}
});
// deregister ngModel watcher to prevent memory leaks
if (angular.isFunction(ngModelDeregisterFn)) ngModelDeregisterFn();
ngModelDeregisterFn = $scope.$watch('ngModel', function (value) {
if($scope.range){
slider.setValue(value);
}else{
slider.setValue(parseFloat(value));
}
slider.relayout();
}, true);
return slider;
}
var watchers = ['min', 'max', 'step', 'range', 'scale', 'ticksLabels', 'ticks'];
angular.forEach(watchers, function (prop) {
$scope.$watch(prop, function () {
slider = initSlider();
});
});
var globalEvents = ['relayout', 'refresh', 'resize'];
angular.forEach(globalEvents, function(event) {
if(angular.isFunction(slider[event])) {
$scope.$on('slider:' + event, function () {
slider[event]();
});
}
});
}
};
}])
;
});
+50
View File
@@ -0,0 +1,50 @@
// !!!
// This module is manually patched by us to not only report valid domains, but verify that subdomains are not accepted
// !!!
'use strict';
angular.module('ngTld', [])
.factory('ngTld', ngTld)
.directive('checkTld', checkTld);
function ngTld() {
function isValid(path) {
// https://github.com/oncletom/tld.js/issues/58
return (path.slice(-1) !== '.') && tld.isValid(path);
}
function tldExists(path) {
return (path.slice(-1) !== '.') && path === tld.getDomain(path);
}
function isSubdomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path !== tld.getDomain(path);
}
function isNakedDomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path === tld.getDomain(path);
}
return {
isValid: isValid,
tldExists: tldExists,
isSubdomain: isSubdomain,
isNakedDomain: isNakedDomain
};
}
function checkTld(ngTld) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
ngModel.$validators.invalidTld = function(modelValue, viewValue) {
return ngTld.tldExists(ngModel.$viewValue.toLowerCase());
};
ngModel.$validators.invalidSubdomain = function(modelValue, viewValue) {
return !ngTld.isSubdomain(ngModel.$viewValue.toLowerCase());
};
}
};
}
+1913
View File
File diff suppressed because one or more lines are too long
-155
View File
@@ -1,155 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Setup</title>
<meta name="description" content="Cloudron Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/activation.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container" ng-show="initialized">
<div class="row" ng-show="view === 'owner'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form role="form" name="ownerForm" ng-submit="owner.submit()" novalidate>
<div class="row">
<div class="col-md-12 text-center">
<h1>Welcome to Cloudron</h1>
<h3>Set up Admin Account</h3>
<p class="has-error text-center" ng-show="owner.error.generic">{{ owner.error.generic }}</p>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': ownerForm.displayName.$dirty && ownerForm.displayName.$invalid }">
<label class="control-label" for="inputDisplayName">Full Name</label>
<input type="text" class="form-control" ng-model="owner.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" ng-disabled="owner.busy" autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.email.$dirty && ownerForm.email.$invalid) || (!ownerForm.email.$dirty && owner.error.email) }">
<label class="control-label" for="inputEmail">Email <sup><a ng-href="https://docs.cloudron.io/installation/#admin-account" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="email" class="form-control" ng-model="owner.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" ng-disabled="owner.busy">
<small>A valid email is required for Let's Encrypt certificates. This email is local to your Cloudron. </small>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.username.$dirty && ownerForm.username.$invalid) || (!ownerForm.username.$dirty && owner.error.username) }">
<label class="control-label" for="inputUsername">Username</label>
<input type="text" class="form-control" ng-model="owner.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off" ng-disabled="owner.busy">
<small>{{ owner.error.username }}</small>
</div>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': ownerForm.password.$dirty && ownerForm.password.$invalid }">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" ng-model="owner.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off" ng-disabled="owner.busy" password-reveal>
<small><span ng-show="ownerForm.password.$dirty && ownerForm.password.$invalid">Password must be at least 8 characters</span> &nbsp;</small>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-success" ng-disabled="ownerForm.$invalid || owner.busy"><i class="fa fa-circle-notch fa-spin" ng-show="owner.busy"></i> Create Admin</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="row" ng-show="view === 'finished'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px 40px;">
<div class="row">
<div class="col-md-12 text-center">
<h1>Cloudron is ready to use</h1>
</div>
</div>
<p>
&nbsp; &nbsp; Before you start:
<ul class="fa-ul">
<li><i class="fa-li fa fa-users"></i>
<b>User management</b>: Cloudron has a central user directory. When installing an app,
you can set it up to authenticate against this directory.
</li>
<br/>
<li><i class="fa-li fa fa-envelope-open"></i>
<b>Email Configuration</b>: Apps are configured to send email based on the settings in the Email view.
This saves you the trouble of having to configure mail settings inside each app.
</li>
<br/>
<li><i class="fa-li fa fa-archive"></i>
<b>Backups</b>: Store your backups on storage services completely independent from your server.
You can use backups to seamlessly migrate your setup to another server.
</li>
<br/>
<li><i class="fa-li fa fa-birthday-cake"></i>
<b>Updates</b>: The Cloudron team tracks upstream releases and publishes app updates after testing.
Your apps are kept fresh &amp; secure.
</li>
</ul>
</p>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a class="btn btn-success" ng-href="firstTimeLoginUrl">Proceed to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+1 -1
View File
@@ -23,7 +23,7 @@
height: 100%;
width: 100%;
text-align: center;
font-family: "Noto Sans", Helvetica, Arial, sans-serif;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
line-height: 1.846;
}
+1 -6
View File
@@ -6,11 +6,6 @@ tmp.forEach(function (pair) {
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
});
var redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
window.location.href = redirectTo;
window.location.href = '/';
</script>
+10 -1
View File
@@ -15,10 +15,11 @@
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/slick.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/bootstrap-slider/bootstrap-slider.min.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -49,6 +50,10 @@
<script type="text/javascript" src="/3rdparty/js/angular-fittext.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js?<%= revision %>"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-tld.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></script>
@@ -68,6 +73,10 @@
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Bootstrap slider -->
<script type="text/javascript" src="/3rdparty/bootstrap-slider/bootstrap-slider.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/bootstrap-slider/slider.js?<%= revision %>"></script>
<!-- Anugular Multiselect https://github.com/sebastianha/angular-bootstrap-multiselect -->
<script type="text/javascript" src="/3rdparty/js/angular-bootstrap-multiselect.js?<%= revision %>"></script>
-100
View File
@@ -1,100 +0,0 @@
'use strict';
/* global angular, window, document, localStorage, redirectIfNeeded */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.setupToken = '';
$scope.firstTimeLoginUrl = '';
$scope.owner = {
error: null,
busy: false,
email: '',
displayName: '',
username: '',
password: '',
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
Client.createAdmin(data, function (error, autoLoginToken) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = autoLoginToken;
$scope.firstTimeLoginUrl = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
setView('finished');
});
}
};
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
} else {
$scope.view = 'owner';
}
}
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'activation')) return; // redirected to some other view...
setView(search.view);
$scope.setupToken = search.setupToken;
$scope.initialized = true;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
});
}
init();
}]);
+71 -165
View File
@@ -171,7 +171,6 @@ const REGIONS_WASABI = [
const REGIONS_DIGITALOCEAN = [
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
@@ -182,7 +181,6 @@ const REGIONS_DIGITALOCEAN = [
// https://www.exoscale.com/datacenters/
const REGIONS_EXOSCALE = [
{ name: 'Vienna (AT-VIE-1)', value: 'https://sos-at-vie-1.exo.io' },
{ name: 'Vienna (AT-VIE-2)', value: 'https://sos-at-vie-2.exo.io' },
{ name: 'Sofia (BG-SOF-1)', value: 'https://sos-bg-sof-1.exo.io' },
{ name: 'Zurich (CH-DK-2)', value: 'https://sos-ch-dk-2.exo.io' },
{ name: 'Geneva (CH-GVA-2)', value: 'https://sos-ch-gva-2.exo.io' },
@@ -220,14 +218,13 @@ const REGIONS_LINODE = [
// note: ovh also has a storage endpoint but that only supports path style access (https://docs.ovh.com/au/en/storage/object-storage/s3/location/)
const REGIONS_OVH = [
{ name: 'Beauharnois (BHS)', value: 'https://s3.bhs.io.cloud.ovh.net', region: 'bhs' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3.de.io.cloud.ovh.net', region: 'de' },
{ name: 'Gravelines (GRA)', value: 'https://s3.gra.io.cloud.ovh.net', region: 'gra' },
{ name: 'Roubaix (RBX)', value: 'https://s3.rbx.io.cloud.ovh.net', region: 'rbx' },
{ name: 'Strasbourg (SBG)', value: 'https://s3.sbg.io.cloud.ovh.net', region: 'sbg' },
{ name: 'London (UK)', value: 'https://s3.uk.io.cloud.ovh.net', region: 'uk' },
{ name: 'Sydney (SYD)', value: 'https://s3.syd.io.cloud.ovh.net', region: 'syd' },
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.io.cloud.ovh.net', region: 'waw' },
{ name: 'Beauharnois (BHS)', value: 'https://s3.bhs.cloud.ovh.net', region: 'bhs' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3.de.cloud.ovh.net', region: 'de' },
{ name: 'Gravelines (GRA)', value: 'https://s3.gra.cloud.ovh.net', region: 'gra' },
{ name: 'Strasbourg (SBG)', value: 'https://s3.sbg.cloud.ovh.net', region: 'sbg' },
{ name: 'London (UK)', value: 'https://s3.uk.cloud.ovh.net', region: 'uk' },
{ name: 'Sydney (SYD)', value: 'https://s3.syd.cloud.ovh.net', region: 'syd' },
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' },
];
const ENDPOINTS_OVH = [
@@ -242,10 +239,9 @@ const ENDPOINTS_OVH = [
// https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints
const REGIONS_IONOS = [
{ name: 'Berlin (eu-central-3)', value: 'https://s3.eu-central-3.ionoscloud.com', region: 'de' }, // default. contract-owned
{ name: 'Frankfurt (DE)', value: 'https://s3.eu-central-1.ionoscloud.com', region: 'de' },
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' },
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' },
{ name: 'Frankfurt (DE)', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' }, // default
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' }, // default
];
// this is not used anywhere because upcloud needs endpoint URL. we detect region from the URL (https://upcloud.com/data-centres)
@@ -331,7 +327,7 @@ function prettyBinarySize(size, fallback) {
// we can also use KB here (JEDEC)
var i = Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(3) * 1 + ' ' + ['B', 'KiB', 'MiB', 'GiB', 'TiB'][i];
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'KiB', 'MiB', 'GiB', 'TiB'][i];
}
// decimal units (SI) 1000 based
@@ -369,8 +365,6 @@ angular.module('Application').filter('trKeyFromPeriod', function () {
angular.module('Application').filter('prettyDate', function ($translate) {
// http://ejohn.org/files/pretty.js
return function prettyDate(utc) {
if (utc === null) return $translate.instant('main.prettyDate.never', {});
var date = new Date(utc), // this converts utc into browser timezone and not cloudron timezone!
diff = (((new Date()).getTime() - date.getTime()) / 1000) + 30, // add 30seconds for clock skew
day_diff = Math.floor(diff / 86400);
@@ -454,73 +448,6 @@ function translateFilterFactory($parse, $translate) {
translateFilterFactory.displayName = 'translateFilterFactory';
angular.module('Application').filter('tr', translateFilterFactory);
// checks provision status and redirects to correct view
// {
// setup: { active, message, errorMessage }
// restore { active, message, errorMessage }
// activated
// adminFqn
// }
// returns true if redirected . currentView is one of dashboard/restore/setup/activation
function redirectIfNeeded(status, currentView) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return false;
}
if (status.activated) {
console.log('Already activated');
if (currentView === 'dashboard') {
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setup.html' + window.location.search;
return true;
}
return false;
}
window.location.href = 'https://' + status.adminFqdn + '/';
return true;
}
if (status.setup.active) {
console.log('Setup is active');
if (currentView === 'setup') return false;
window.location.href = '/setup.html' + window.location.search;
return true;
}
if (status.restore.active) {
console.log('Restore is active');
if (currentView === 'restore') return;
window.location.href = '/restore.html' + window.location.search;
return true;
}
if (status.adminFqdn) {
console.log('adminFqdn is set');
// if we are here from https://ip/activation.html ,go to https://admin/activation.html
if (status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
return true;
}
if (currentView === 'activation') return false;
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
return true;
}
if (currentView === 'dashboard') {
window.location.href = '/setup.html' + window.location.search;
return true;
}
// if we are here, proceed with current view
return false;
}
// ----------------------------------------------
// Cloudron REST API wrapper
@@ -710,7 +637,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
// window.location fallback for websocket connections which do not have relative uris
this.apiOrigin = '<%= apiOrigin %>' || window.location.origin;
this.avatar = '';
this.background = '';
this._availableLanguages = ['en'];
this._appstoreAppCache = [];
@@ -899,31 +825,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.hasCloudronBackground = function (callback) {
get('/api/v1/branding/cloudron_background', null, function (error, data, status) {
if (error && error.statusCode !== 404) callback(error);
else if (error) callback(null, false);
else callback(null, status === 200);
});
};
Client.prototype.changeCloudronBackground = function (background, callback) {
var fd = new FormData();
if (background) fd.append('background', background);
var config = {
headers: { 'Content-Type': undefined },
transformRequest: angular.identity
};
post('/api/v1/branding/cloudron_background', fd, config, function (error, data, status) {
if (error) return callback(error);
if (status !== 202) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.changeCloudronAvatar = function (avatarFile, callback) {
var fd = new FormData();
fd.append('avatar', avatarFile);
@@ -958,7 +859,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
ports: config.ports,
portBindings: config.portBindings,
accessRestriction: config.accessRestriction,
cert: config.cert,
key: config.key,
@@ -980,7 +881,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
ports: config.ports,
portBindings: config.portBindings,
backupId: config.backupId,
overwriteDns: !!config.overwriteDns
};
@@ -1053,15 +954,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.ackAppChecklistItem = function (appId, key, acknowledged, callback) {
put('/api/v1/apps/' + appId + '/checklist/' + key, { done: acknowledged }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 202) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.updateApp = function (id, manifest, options, callback) {
var data = {
appStoreId: manifest.id + '@' + manifest.version,
@@ -1147,7 +1039,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
const storageConfig = Object.assign({}, backupConfig);
delete storageConfig.limits;
post('/api/v1/backups/config/storage', storageConfig, null, function (error, data, status) {
post('/api/v1/backups/config/storage', backupConfig, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -1400,6 +1292,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.getUpdateInfo = function (callback) {
if (!this._userInfo.isAtLeastAdmin) return callback(new Error('Not allowed'));
get('/api/v1/updater/updates', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -1591,7 +1485,16 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.restore = function (data, callback) {
Client.prototype.restore = function (backupConfig, remotePath, version, ipv4Config, skipDnsSetup, setupToken, callback) {
var data = {
backupConfig: backupConfig,
remotePath: remotePath,
version: version,
ipv4Config: ipv4Config,
skipDnsSetup: skipDnsSetup,
setupToken: setupToken
};
post('/api/v1/provision/restore', data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status));
@@ -1927,6 +1830,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.getAppLimits = function (appId, callback) {
get('/api/v1/apps/' + appId + '/limits', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.limits);
});
};
Client.prototype.getAppWithTask = function (appId, callback) {
var that = this;
@@ -1976,15 +1888,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.detectIp = function (callback) {
post('/api/v1/provision/detect_ip', {}, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.setup = function (data, callback) {
post('/api/v1/provision/setup', data, null, function (error, data, status) {
if (error) return callback(error);
@@ -2010,7 +1913,10 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (error) return callback(error);
if (status !== 201) return callback(new ClientError(status, result));
callback(null, result.token);
that.setToken(result.token);
that.setUserInfo({ username: data.username, email: data.email, admin: true, twoFactorAuthenticationEnabled: false, source: '', avatarUrl: null });
callback(null, result.activated);
});
};
@@ -2176,6 +2082,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.disks = function (callback) {
get('/api/v1/system/disks', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.diskUsage = function (callback) {
get('/api/v1/system/disk_usage', null, function (error, data, status) {
if (error) return callback(error);
@@ -2301,7 +2216,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
// amend properties to mimick full app
data.applinks.forEach(function (applink) {
applink.type = APP_TYPES.LINK;
applink.fqdn = applink.upstreamUri;
applink.fqdn = new URL(applink.upstreamUri).hostname;
applink.manifest = { addons: {}};
applink.installationState = ISTATES.INSTALLED;
applink.runState = RSTATES.RUNNING;
@@ -2642,10 +2557,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
this.config(function (error, result) {
if (error) return callback(error);
that.getUpdateInfo(function (error, info) {
if (error) return callback(error);
that.getUpdateInfo(function (error, info) { // note: non-admin users may get access denied for this
if (!error) result.update = info.update; // attach update information to config object
result.update = info.update;
that.setConfig(result);
callback(null);
});
@@ -2813,8 +2727,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.login = function () {
this.setToken(null);
localStorage.setItem('redirectToHash', window.location.hash);
// start oidc flow
window.location.href = this.apiOrigin + '/openid/auth?client_id=' + ('<%= apiOrigin %>' ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
};
@@ -3148,7 +3060,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.setSpamAcl = function (acl, callback) {
post('/api/v1/mailserver/spam_acl', { allowlist: acl.allowlist, blocklist: acl.blocklist }, null, function (error, data, status) {
post('/api/v1/mailserver/spam_acl', { whitelist: acl.whitelist, blacklist: acl.blacklist }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -3526,6 +3438,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
mountOptions: mountOptions
};
console.log('---update', data)
post('/api/v1/volumes/' + volumeId, data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
@@ -3713,8 +3627,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
var ACTION_USER_UPDATE = 'user.update';
var ACTION_USER_TRANSFER = 'user.transfer';
var ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE = 'userdirectory.profileconfig.update';
var ACTION_MAIL_LOCATION = 'mail.location';
var ACTION_MAIL_ENABLED = 'mail.enabled';
var ACTION_MAIL_DISABLED = 'mail.disabled';
@@ -3746,11 +3658,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
return pre + (app.label || app.fqdn || app.subdomain) + ' (' + app.manifest.title + ') ';
}
function eventBy() {
if (eventLog.source && eventLog.source.username) return ' by ' + eventLog.source.username;
return '';
}
switch (eventLog.action) {
case ACTION_ACTIVATE:
return 'Cloudron was activated';
@@ -3765,22 +3672,24 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (!data.app) return '';
app = data.app;
var q = function (x) {
return '"' + x + '"';
};
if ('accessRestriction' in data) { // since it can be null
return 'Access restriction ' + appName('of', app) + ' was changed';
} else if ('operators' in data) {
return 'Operators ' + appName('of', app) + ' was changed';
} else if (data.label) {
return `Label ${appName('of', app)} was set to ${data.label}`;
return 'Label ' + appName('of', app) + ' was set to ' + q(data.label);
} else if (data.tags) {
return `Tags ${appName('of', app)} was set to ${data.tags.join(', ')}`;
return 'Tags ' + appName('of', app) + ' was set to ' + q(data.tags.join(','));
} else if (data.icon) {
return 'Icon ' + appName('of', app) + ' was changed';
} else if (data.memoryLimit) {
return 'Memory limit ' + appName('of', app) + ' was set to ' + prettyBinarySize(data.memoryLimit);
} else if (data.cpuShares) { // replaced by cpuQuota in 8.0
return 'Memory limit ' + appName('of', app) + ' was set to ' + data.memoryLimit;
} else if (data.cpuShares) {
return 'CPU shares ' + appName('of', app) + ' was set to ' + Math.round((data.cpuShares * 100)/1024) + '%';
} else if (data.cpuQuota) {
return 'CPU quota ' + appName('of', app) + ' was set to ' + data.cpuQuota + '%';
} else if (data.env) {
return 'Env vars ' + appName('of', app) + ' was changed';
} else if ('debugMode' in data) { // since it can be null
@@ -3790,9 +3699,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
return appName('', app, 'App') + ' was taken out of repair mode';
}
} else if ('enableBackup' in data) {
return 'Automatic backups ' + appName('of', app) + ' was ' + (data.enableBackup ? 'enabled' : 'disabled');
return 'Automatic backups ' + appName('of', app) + ' were ' + (data.enableBackup ? 'enabled' : 'disabled');
} else if ('enableAutomaticUpdate' in data) {
return 'Automatic updates ' + appName('of', app) + ' was ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
return 'Automatic updates ' + appName('of', app) + ' were ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
} else if ('reverseProxyConfig' in data) {
return 'Reverse proxy configuration ' + appName('of', app) + ' was updated';
} else if ('upstreamUri' in data) {
@@ -3827,11 +3736,11 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
} else {
return 'Icon ' + appName('of', app) + ' was reset';
}
} else if ('mailboxName' in data) {
} else if (('mailboxName' in data) && data.mailboxName !== data.app.mailboxName) {
if (data.mailboxName) {
return `Mailbox ${appName('of', app)} was set to ${data.mailboxDisplayName || '' } ${data.mailboxName}@${data.mailboxDomain}`;
return 'Mailbox ' + appName('of', app) + ' was set to ' + q(data.mailboxName);
} else {
return 'Mailbox ' + appName('of', app) + ' was disabled';
return 'Mailbox ' + appName('of', app) + ' was reset';
}
}
@@ -3840,7 +3749,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_APP_INSTALL:
if (!data.app) return '';
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app) + eventBy();
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app);
case ACTION_APP_RESTORE:
if (!data.app) return '';
@@ -4046,11 +3955,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_USER_LOGIN:
if (data.mailboxId) {
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to mailbox ' + data.mailboxId;
} else if (data.appId) {
} else {
app = this.getCachedAppSync(data.appId);
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
} else { // can happen with directoryserver
return 'User ' + (data.user ? data.user.username : data.userId) + ' authenticated';
}
case ACTION_USER_LOGIN_GHOST:
@@ -4059,9 +3966,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_USER_LOGOUT:
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged out';
case ACTION_USER_DIRECTORY_PROFILE_CONFIG_UPDATE:
return 'User directory profile config updated. Mandatory 2FA: ' + (data.config.mandatory2FA) + ' Lock profiles: ' + (data.config.lockUserProfiles);
case ACTION_DYNDNS_UPDATE: {
details = data.errorMessage ? 'Error updating DNS. ' : 'Updated DNS. ';
if (data.fromIpv4 !== data.toIpv4) details += 'From IPv4 ' + data.fromIpv4 + ' to ' + data.toIpv4 + '. ';
@@ -4097,6 +4001,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (source.appId) {
var app = this.getCachedAppSync(source.appId);
line += ' - ' + (app ? app.fqdn : source.appId);
} else if (source.ip) {
line += ' - ' + source.ip;
}
return line;
+28 -38
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false, window, document, localStorage, redirectIfNeeded */
/* global angular:false */
/* global $:false */
/* global async */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES,NOTIFICATION_TYPES */
@@ -19,7 +19,7 @@ if (search.accessToken) {
}
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.multiselect']);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.bootstrap-slider', 'ngTld', 'ui.multiselect']);
app.config(['NotificationProvider', function (NotificationProvider) {
NotificationProvider.setOptions({
@@ -305,36 +305,6 @@ app.filter('installationActive', function () {
};
});
// color indicator in app list
app.filter('installationStateClass', function () {
const ERROR_CLASS = 'status-error';
const BUSY_CLASS = 'status-starting fa-beat-fade';
const INACTIVE_CLASS = 'status-inactive';
const ACTIVE_CLASS = 'status-active';
return function(app) {
if (!app) return '';
switch (app.installationState) {
case ISTATES.ERROR: return ERROR_CLASS;
case ISTATES.INSTALLED: {
if (app.debugMode) {
return INACTIVE_CLASS;
} else {
if (app.runState === RSTATES.RUNNING) {
if (!app.health) return BUSY_CLASS; // no data yet
if (app.type === APP_TYPES.LINK || app.health === HSTATES.HEALTHY) return ACTIVE_CLASS;
return ERROR_CLASS; // dead/exit/unhealthy
} else {
return INACTIVE_CLASS;
}
}
}
default: return BUSY_CLASS;
}
};
});
// this appears in the app grid
app.filter('installationStateLabel', function () {
return function(app) {
@@ -429,7 +399,7 @@ app.filter('errorSuggestion', function () {
};
});
app.filter('canUpdate', function () {
app.filter('readyToUpdate', function () {
return function (apps) {
return apps.every(function (app) {
return (app.installationState === ISTATES.ERROR) || (app.installationState === ISTATES.INSTALLED);
@@ -736,10 +706,7 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
};
function redirectOnMandatory2FA() {
if (Client.getConfig().mandatory2FA) {
if (Client.getUserInfo().twoFactorAuthenticationEnabled) return; // user already has 2fa
if (Client.getUserInfo().source && $scope.config.external2FA) return; // 2fa is external
if (Client.getConfig().mandatory2FA && !Client.getUserInfo().twoFactorAuthenticationEnabled) {
$location.path('/profile').search({ setup2fa: true });
}
}
@@ -778,12 +745,35 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
});
}
function redirectIfNeeded(status) {
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
if (status.restore.active || status.restore.errorMessage) { // show the error message in restore page
window.location.href = '/restore.html' + window.location.search;
} else if (status.adminFqdn) {
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
} else {
window.location.href = '/setupdns.html' + window.location.search;
}
return true;
}
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setupdns.html' + window.location.search;
return true;
}
return false;
}
// this loads the very first thing when accessing via IP or domain
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'dashboard')) return; // we got redirected...
if (redirectIfNeeded(status)) return;
// check version and force reload if needed
if (!localStorage.version) {
+36 -32
View File
@@ -1,11 +1,17 @@
'use strict';
/* global $, angular, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, window, FileReader, document, redirectIfNeeded */
/* global $, angular, tld, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_CONTABO */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('RestoreController', ['$scope', 'Client', function ($scope, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
@@ -45,7 +51,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
password: '',
diskPath: '',
user: '',
seal: true,
seal: false,
port: 22,
privateKey: ''
};
@@ -55,25 +61,27 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.mountOptions.diskPath = '/dev/disk/by-uuid/' + newValue.uuid;
});
$scope.ipv4Config = {
$scope.sysinfo = {
provider: 'generic',
ip: '',
ipv4: '',
ifname: ''
};
$scope.ipv6Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipProviders = [
{ name: 'Disabled', value: 'noop' },
$scope.sysinfoProvider = [
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.s3Regions = REGIONS_S3;
$scope.wasabiRegions = REGIONS_WASABI;
$scope.doSpacesRegions = REGIONS_DIGITALOCEAN;
@@ -231,17 +239,16 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return;
}
var data = {
backupConfig: backupConfig,
remotePath: $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''),
version: version ? version[1] : '',
ipv4Config: $scope.ipv4Config,
ipv6Config: $scope.ipv6Config,
skipDnsSetup: $scope.skipDnsSetup,
setupToken: $scope.setupToken
var sysinfoConfig = {
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ip = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
Client.restore(data, function (error) {
Client.restore(backupConfig, $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''), version ? version[1] : '', sysinfoConfig, $scope.skipDnsSetup, $scope.setupToken, function (error) {
$scope.busy = false;
if (error) {
@@ -296,7 +303,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.busy = false;
$scope.error.generic = status.restore.errorMessage;
} else { // restore worked, redirect to admin page
window.location.href = 'https://' + status.adminFqdn + '/';
window.location.href = '/';
}
return;
}
@@ -355,11 +362,14 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'restore')) return; // redirected to some other view...
if (status.restore.active) return waitForRestore();
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage; // any previous restore error
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage;
if (status.activated) {
window.location.href = '/';
return;
}
Client.getProvisionBlockDevices(function (error, result) {
if (error) {
@@ -378,13 +388,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
Client.detectIp(function (error, ip) { // this is never supposed to error
if (!error) $scope.ipv4Config.provider = ip.ipv4 ? 'generic' : 'noop';
if (!error) $scope.ipv6Config.provider = ip.ipv6 ? 'generic' : 'noop';
$scope.initialized = true;
});
$scope.initialized = true;
});
});
}
+86 -302
View File
@@ -1,336 +1,120 @@
'use strict';
/* global $, angular, Clipboard, ENDPOINTS_OVH, window, FileReader, document, redirectIfNeeded */
/* global angular */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.setupToken = '';
$scope.taskMinutesActive = null;
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.ipv4Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipv6Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipProviders = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'deSEC', value: 'desec' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
$scope.owner = {
error: null,
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
deSecToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
email: '',
displayName: '',
username: '',
password: '',
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
Client.createAdmin(data, function (error) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
setView('finished');
});
};
}
};
function redirectIfNeeded(status) {
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return;
}
// if we are here from https://ip/setup.html ,go to https://admin/setup.html
if (status.adminFqdn && status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/setup.html';
return true;
}
// if we don't have a domain yet, first go to domain setup
if (!status.adminFqdn) {
window.location.href = '/setupdns.html';
return true;
}
if (status.activated) {
window.location.href = '/';
return true;
}
return false;
}
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
var provider = $scope.dnsCredentials.provider;
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'desec') {
config.token = $scope.dnsCredentials.deSecToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'ovh') {
config.endpoint = $scope.dnsCredentials.ovhEndpoint;
config.consumerKey = $scope.dnsCredentials.ovhConsumerKey;
config.appKey = $scope.dnsCredentials.ovhAppKey;
config.appSecret = $scope.dnsCredentials.ovhAppSecret;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
} else {
$scope.view = 'owner';
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
ipv4Config: $scope.ipv4Config,
ipv6Config: $scope.ipv6Config,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getProvisionStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
}
return;
}
if (!error) {
$scope.message = status.setup.message;
$scope.taskMinutesActive = (new Date() - new Date(status.setup.startTime)) / 60000;
}
setTimeout(waitForDnsSetup, 5000);
});
}
function init() {
Client.getProvisionStatus(function (error, status) {
$scope.state = 'waitingForBox';
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'setup')) return; // redirected to some other view...
if (redirectIfNeeded(status)) return;
setView(search.view);
if (status.setup.active) return waitForDnsSetup();
$scope.error.setup = status.setup.errorMessage; // show any previous error
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
// aws marketplace made a policy change that they one cannot provide route53 IAM credentials
$scope.dnsCredentials.provider = 'wildcard';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.provider = status.provider;
$scope.initialized = true;
Client.detectIp(function (error, ip) { // this is never supposed to error
if (!error) $scope.ipv4Config.provider = ip.ipv4 ? 'generic' : 'noop';
if (!error) $scope.ipv6Config.provider = ip.ipv6 ? 'generic' : 'noop';
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
init();
}]);
+2 -5
View File
@@ -71,7 +71,6 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$scope.error = null;
$scope.view = 'setup';
$scope.branding = null;
$scope.dashboardUrl = '';
$scope.profileLocked = !!search.profileLocked;
$scope.existingUsername = !!search.username;
@@ -123,10 +122,8 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$http.post(API_ORIGIN + '/api/v1/auth/setup_account', data).success(function (data, status) {
if (status !== 201) return error(data, status);
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = data.accessToken;
$scope.dashboardUrl = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
// set token to autologin
localStorage.token = data.accessToken;
$scope.view = 'done';
}).error(error);
+353
View File
@@ -0,0 +1,353 @@
'use strict';
/* global $, tld, angular, Clipboard, ENDPOINTS_OVH */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.isDomain = false;
$scope.isSubdomain = false;
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.sysinfo = {
provider: 'generic',
ipv4: '',
ifname: ''
};
$scope.sysinfoProvider = [
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
$scope.$watch('dnsCredentials.domain', function (newVal) {
if (!newVal) {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else if (!tld.getDomain(newVal) || newVal[newVal.length-1] === '.') {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else {
$scope.isDomain = true;
$scope.isSubdomain = tld.getDomain(newVal) !== newVal;
}
});
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
var provider = $scope.dnsCredentials.provider;
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'ovh') {
config.endpoint = $scope.dnsCredentials.ovhEndpoint;
config.consumerKey = $scope.dnsCredentials.ovhConsumerKey;
config.appKey = $scope.dnsCredentials.ovhAppKey;
config.appSecret = $scope.dnsCredentials.ovhAppSecret;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ip = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
ipv4Config: sysinfoConfig,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getProvisionStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
}
return;
}
$scope.message = status.setup.message;
setTimeout(waitForDnsSetup, 5000);
});
}
function initialize() {
Client.getProvisionStatus(function (error, status) {
if (error) {
// During domain migration, the box code restarts and can result in getStatus() failing temporarily
console.error(error);
$scope.state = 'waitingForBox';
return $timeout(initialize, 3000);
}
// domain is currently like a lock flag
if (status.adminFqdn) return waitForDnsSetup();
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
$scope.dnsCredentials.provider = 'route53';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.provider = status.provider;
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
initialize();
}]);
+3 -13
View File
@@ -23,7 +23,7 @@
height: 100%;
width: 100%;
text-align: center;
font-family: "Noto Sans", Helvetica, Arial, sans-serif;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 13px;
line-height: 1.846;
}
@@ -51,19 +51,9 @@
<script type="text/javascript">
window.addEventListener('load', (event) => {
// https://stackoverflow.com/questions/37437890/check-if-url-has-domain-name-and-not-an-ip
const containsLetter = /[a-zA-z]/.test(window.location.hostname); // ignore technicality that IP can contain letters ! http://192.168.0x1.0x1 or http://0xc0.0xa8.1.1
const isIPv6 = location.hostname.startsWith('[') && location.hostname.endsWith(']');
let message;
if (!containsLetter || isIPv6) { // ipv4 or ipv6
message = 'You cannot view Cloudron dashboard by IP address. Instead, navigate to the domain you configured during setup i.e <b>https://my.domain.example</b> .'
+ '<br>If you do not remember your domain, SSH into your server and run <code>cloudron-support --owner-login</code> .'
} else { // hostname
message = 'You are seeing this page because the DNS record of <b>' + window.location.hostname + '</b> is set to this server\'s IP'
document.getElementById('message').innerHTML =
'You are seeing this page because the DNS record of <b>' + window.location.hostname + '</b> is set to this server\'s IP'
+ ' but Cloudron has no app configured for this domain.';
}
document.getElementById('message').innerHTML = message;
});
</script>
</head>
+1 -1
View File
@@ -15,7 +15,7 @@
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
+326 -340
View File
@@ -1,51 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Restore</title>
<meta name="description" content="Cloudron Restore">
<title>Cloudron Restore</title>
<meta name="description" content="Cloudron Restore">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/restore.js"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/restore.js"></script>
</head>
@@ -53,317 +56,300 @@
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container ng-cloak text-center" ng-show="busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="initialized && !busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="configureBackupForm" role="form" novalidate ng-submit="restore()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
</div>
<div class="main-container ng-cloak text-center" ng-show="busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col-md-8 col-md-offset-2 text-center">
<input type="file" id="backupConfigFileInput" style="display:none"/>
<button type="button" class="btn btn-default" onclick="getElementById('backupConfigFileInput').click();">Upload Backup Config</button>
</div>
<br/>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<p class="has-error text-center" ng-show="error">{{ error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="provider" ng-options="a.value as a.name for a in storageProviders" ng-change=clearForm()></select>
</div>
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': error.mountPoint }" ng-show="provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">Mountpoint</label>
<input type="text" class="form-control" ng-model="mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="busy" placeholder="Folder where filesystem is mounted" ng-required="provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">Server IP or Hostname</label>
<input type="text" class="form-control" ng-model="mountOptions.host" id="configureBackupHost" name="host" ng-disabled="busy" placeholder="Server IP or hostname" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="provider === 'cifs'">
<label>
<input type="checkbox" ng-model="mountOptions.seal">Use seal encryption. Requires at least SMB v3</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">Remote Directory</label>
<input type="text" class="form-control" ng-model="mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="busy" placeholder="/share" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">Username ({{ provider }})</label>
<input type="text" class="form-control" ng-model="mountOptions.username" id="configureBackupUsername" name="cifsUsername" ng-disabled="busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">Password ({{ provider }})</label>
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4' || provider === 'xfs'">
<label class="control-label" for="inputConfigureDiskPath">Disk Path</label>
<input type="text" class="form-control" ng-model="mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'ext4' || provider === 'xfs'">
</div>
<!-- Disk -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'disk'">
<label class="control-label">Device</label>
<select class="form-control" ng-model="disk" ng-options="item as item.label for item in blockDevices track by item.path" ng-required="provider === 'disk'"></select>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">SSH Port</label>
<input type="number" class="form-control" ng-model="mountOptions.port" id="configureBackupPort" name="port" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">SSH User</label>
<input type="text" class="form-control" ng-model="mountOptions.user" id="configureBackupUser" name="user" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">SSH Private Key</label>
<textarea class="form-control" ng-model="mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="busy"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': error.backupFolder }" ng-show="provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'filesystem'">
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="busy" placeholder="URL" ng-required="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': error.bucket }" ng-show="s3like(provider) || provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.prefix }" ng-show="provider !== 'filesystem' && provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="busy" ng-required="provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">Region</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="region" ng-disabled="busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="busy" ng-required="provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="busy" ng-required="provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="busy" ng-required="provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="busy" ng-required="provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="busy" ng-required="provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="busy" ng-required="provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="busy" ng-required="provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="busy" ng-required="provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'contabo-objectstorage'">
<label class="control-label" for="inputConfigureBackupContaboRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupContaboRegion" ng-model="endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="busy" ng-required="provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.accessKeyId }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.secretAccessKey }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.gcsKeyInput }" ng-show="provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="busy" ng-required="provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="key = ''" ng-model="format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
<label class="control-label" for="inputConfigureBackupPassword">Encryption password <span ng-hide="encrypted">(optional)</span></label>
<input type="text" class="form-control" ng-model="password" id="inputConfigureBackupPassword" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups" ng-required="encrypted">
</div>
<div class="checkbox" ng-show="format === 'rsync' && password.length !== 0">
<label>
<input type="checkbox" ng-model="encryptedFilenames">Decrypt Filenames</input>
</label>
</div>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="skipDnsSetup"><b>Dry run</b></sup>
</label>
<br/>
<small>When enabled, apps are restored but the DNS records are not updated to point to this server. To access the dashboard, this browser's host must have an entry in <code>/etc/hosts</code> for the dashboard domain to this server's IP.
See the <a href="https://docs.cloudron.io/backups/#dry-run" target="_blank">docs</a> for more information.</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
<div uib-collapse="!advancedVisible">
<!-- IPv4 provider -->
<div class="form-group">
<label class="control-label">IPv4 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv4Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv4 Fixed -->
<div class="form-group" ng-show="ipv4Config.provider === 'fixed'">
<label class="control-label">IPv4 Address</label>
<input type="text" class="form-control" ng-model="ipv4Config.ip" name="ipv4" ng-required="ipv4Config.provider === 'fixed'">
</div>
<!-- IPv4 Network Interface -->
<div class="form-group" ng-show="ipv4Config.provider === 'network-interface'">
<label class="control-label">IPv4 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv4Config.ifname" name="ifname4" ng-required="ipv4Config.provider === 'network-interface'">
</div>
<!-- IPv6 provider -->
<div class="form-group">
<label class="control-label">IPv6 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv6 Fixed -->
<div class="form-group" ng-show="ipv6Config.provider === 'fixed'">
<label class="control-label">IPv6 Address</label>
<input type="text" class="form-control" ng-model="ipv6Config.ip" name="ipv6" ng-required="ipv6Config.provider === 'fixed'">
</div>
<!-- IPv6 Network Interface -->
<div class="form-group" ng-show="ipv6Config.provider === 'network-interface'">
<label class="control-label">IPv6 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv6Config.ifname" name="ifname6" ng-required="ipv6Config.provider === 'network-interface'">
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="configureBackupForm.$invalid"/><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Restore</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
<div class="main-container ng-cloak" ng-show="initialized && !busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="configureBackupForm" role="form" novalidate ng-submit="restore()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
</div>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col-md-8 col-md-offset-2 text-center">
<input type="file" id="backupConfigFileInput" style="display:none"/>
<button type="button" class="btn btn-default" onclick="getElementById('backupConfigFileInput').click();">Upload Backup Config</button>
</div>
<br/>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<p class="has-error text-center" ng-show="error">{{ error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="provider" ng-options="a.value as a.name for a in storageProviders" ng-change=clearForm()></select>
</div>
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': error.mountPoint }" ng-show="provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">Mountpoint</label>
<input type="text" class="form-control" ng-model="mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="busy" placeholder="Folder where filesystem is mounted" ng-required="provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">Server IP or Hostname</label>
<input type="text" class="form-control" ng-model="mountOptions.host" id="configureBackupHost" name="host" ng-disabled="busy" placeholder="Server IP or hostname" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="provider === 'cifs'">
<label>
<input type="checkbox" ng-model="mountOptions.seal">Use seal encryption. Requires at least SMB v3</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">Remote Directory</label>
<input type="text" class="form-control" ng-model="mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="busy" placeholder="/share" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">Username ({{ provider }})</label>
<input type="text" class="form-control" ng-model="mountOptions.username" id="configureBackupUsername" name="cifsUsername" ng-disabled="busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">Password ({{ provider }})</label>
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4' || provider === 'xfs'">
<label class="control-label" for="inputConfigureDiskPath">Disk Path</label>
<input type="text" class="form-control" ng-model="mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'ext4' || provider === 'xfs'">
</div>
<!-- Disk -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'disk'">
<label class="control-label">Device</label>
<select class="form-control" ng-model="disk" ng-options="item as item.label for item in blockDevices track by item.path" ng-required="provider === 'disk'"></select>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">SSH Port</label>
<input type="number" class="form-control" ng-model="mountOptions.port" id="configureBackupPort" name="port" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">SSH User</label>
<input type="text" class="form-control" ng-model="mountOptions.user" id="configureBackupUser" name="user" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">SSH Private Key</label>
<textarea class="form-control" ng-model="mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="busy"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': error.backupFolder }" ng-show="provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'filesystem'">
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="busy" placeholder="URL" ng-required="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': error.bucket }" ng-show="s3like(provider) || provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.prefix }" ng-show="provider !== 'filesystem' && provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="busy" ng-required="provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">Region</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="region" ng-disabled="busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="busy" ng-required="provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="busy" ng-required="provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="busy" ng-required="provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="busy" ng-required="provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="busy" ng-required="provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="busy" ng-required="provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="busy" ng-required="provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="busy" ng-required="provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'contabo-objectstorage'">
<label class="control-label" for="inputConfigureBackupContaboRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupContaboRegion" ng-model="endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="busy" ng-required="provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.accessKeyId }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.secretAccessKey }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.gcsKeyInput }" ng-show="provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="busy" ng-required="provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="key = ''" ng-model="format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
<label class="control-label" for="inputConfigureBackupPassword">Encryption password <span ng-hide="encrypted">(optional)</span></label>
<input type="text" class="form-control" ng-model="password" id="inputConfigureBackupPassword" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups" ng-required="encrypted">
</div>
<div class="checkbox" ng-show="format === 'rsync' && password.length !== 0">
<label>
<input type="checkbox" ng-model="encryptedFilenames">Decrypt Filenames</input>
</label>
</div>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="skipDnsSetup"><b>Dry run</b></sup>
</label>
<br/>
<small>When enabled, apps are restored but the DNS records are not updated to point to this server. To access the dashboard, this browser's host must have an entry in <code>/etc/hosts</code> for the dashboard domain to this server's IP.
See the <a href="https://docs.cloudron.io/backups/#dry-run" target="_blank">docs</a> for more information.</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">IP Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.provider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.provider === 'fixed'" ng-class="{ 'has-error': error.ipv4 }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.ipv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'fixed'">
<p class="has-error" ng-show="error.ipv4">{{ error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.provider === 'network-interface'" ng-class="{ 'has-error': error.ifname }">
<label class="control-label">Interface Name</label>
<input type="text" class="form-control" ng-model="sysinfo.ifname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'network-interface'">
<p class="has-error" ng-show="error.ifname">{{ error.ifname }}</p>
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="configureBackupForm.$invalid"/><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Restore</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+113 -351
View File
@@ -1,390 +1,152 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Domain Setup</title>
<meta name="description" content="Cloudron Domain Setup">
<title>Cloudron Setup</title>
<meta name="description" content="Cloudron Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setup.js"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setup.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<body class="setup" ng-app="Application" ng-controller="SetupController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
<br/>
<br/>
<p ng-show="taskMinutesActive >= 4">
If setup appears stuck, it can be restarted by running <code class="clipboard hand" data-clipboard-text="systemctl restart box" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">systemctl restart box</code> and reloading this page.
</p>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="state === 'initialized'">
<div class="row">
<div class="main-container" ng-show="initialized">
<div class="row" ng-show="view === 'owner'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="dnsCredentialsForm" role="form" novalidate ng-submit="setDnsCredentials()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Domain Setup</h1>
<p class="has-error text-center" ng-show="error.setup">{{ error.setup }}</p>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<label class="control-label">Domain <sup><a ng-href="https://docs.cloudron.io/installation/#domain-setup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<div class="text-danger" ng-show="dnsCredentials.domain.indexOf('my.') === 0 && dnsCredentials.domain.length > 3">Are you sure about this domain? The dashboard will be at <b>my.{{ dnsCredentials.domain }}</b></div>
<p style="margin-top: 5px; font-size: 13px;">
Apps will be installed on subdomains of this domain. The dashboard will be available on the <b>my</b> subdomain. You can add more domains later.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<p class="has-error text-center" ng-show="error.dnsCredentials">{{ error.dnsCredentials }}</p>
<div class="form-group">
<label class="control-label">DNS Provider</label>
<select class="form-control" ng-model="dnsCredentials.provider" ng-options="a.value as a.name for a in dnsProvider" ng-disabled="dnsCredentials.busy" ng-change="setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div ng-if="provider === 'ami'" ng-show="dnsCredentials.provider === 'route53'">
<b class="has-error">This feature is disabled in AWS Marketplace AMI. <a href="https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html" target="_blank">AWS Marketplace Policy</a> disallows
AMIs from requesting IAM credentials from users to access Route53 hosted domains. Please use the Wildcard or Manual provider instead.</b>
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="dnsCredentials.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-required="dnsCredentials.provider === 'gcdns'" ng-disabled="dnsCredentials.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.gandiApiKey.$dirty && dnsCredentialsForm.gandiApiKey.$invalid }" ng-show="dnsCredentials.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.gandiApiKey" name="gandiApiKey" placeholder="API Key" ng-required="dnsCredentials.provider === 'gandi'" ng-disabled="dnsCredentials.busy">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiKey.$dirty && dnsCredentialsForm.godaddyApiKey.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiKey" name="godaddyApiKey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiSecret.$dirty && dnsCredentialsForm.godaddyApiSecret.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiSecret" name="godaddyApiSecret" placeholder="API Secret" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupCustomerNumber.$dirty && dnsCredentialsForm.netcupCustomerNumber.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">Customer Number</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiKey.$dirty && dnsCredentialsForm.netcupApiKey.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiKey" name="netcupApiKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiPassword.$dirty && dnsCredentialsForm.netcupApiPassword.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Password</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiPassword" name="netcupApiPassword" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="dnsCredentials.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="dnsCredentials.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="dnsCredentials.busy">
</div>
<div class="checkbox" ng-show="dnsCredentials.provider === 'cloudflare'">
<label>
<input type="checkbox" ng-model="dnsCredentials.cloudflareDefaultProxyStatus"> Enable proxying for new DNS records
<sup><a ng-href="https://docs.cloudron.io/domains/#cloudflare-dns" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComUsername.$dirty && dnsCredentialsForm.nameComUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComUsername" name="nameComUsername" placeholder="Name.com Username" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComToken.$dirty && dnsCredentialsForm.nameComToken.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComToken" name="nameComToken" placeholder="Name.com API Token" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapUsername.$dirty && dnsCredentialsForm.namecheapUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapUsername" name="namecheapUsername" placeholder="Namecheap Username" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapApiKey.$dirty && dnsCredentialsForm.namecheapApiKey.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="dnsCredentials.provider === 'namecheap'"><b>The server IP needs to be whitelisted for this API Key.</b></p>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapApiKey" name="namecheapApiKey" placeholder="Namecheap API Key" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Linode -->
<p class="form-group" ng-show="dnsCredentials.provider === 'linode'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.linodeToken" name="linodeToken" ng-required="dnsCredentials.provider === 'linode'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Bunny -->
<p class="form-group" ng-show="dnsCredentials.provider === 'bunny'">
<label class="control-label">Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- dnsimple -->
<p class="form-group" ng-show="dnsCredentials.provider === 'dnsimple'">
<label class="control-label">Access Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-required="dnsCredentials.provider === 'dnsimple'" ng-disabled="dnsCredentials.busy">
</p>
<!-- OVH -->
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">Endpoint</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="dnsCredentials.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'"></select>
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Consumer Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppKey" name="ovhAppKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppSecret" name="ovhAppSecret" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<!-- Porkbun -->
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.hetznerToken" name="hetznerToken" ng-required="dnsCredentials.provider === 'hetzner'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Vultr -->
<p class="form-group" ng-show="dnsCredentials.provider === 'vultr'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.vultrToken" name="vultrToken" ng-required="dnsCredentials.provider === 'vultr'" ng-disabled="dnsCredentials.busy">
</p>
<!-- deSEC -->
<p class="form-group" ng-show="dnsCredentials.provider === 'desec'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.deSecToken" name="deSecToken" ng-required="dnsCredentials.provider === 'desec'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Wildcard -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'wildcard'">
<span>Set up A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}.</b> and <b>{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.</span>
</p>
<!-- Manual -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'manual'">
<span>Set up an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.<br/></span>
</p>
<p class="small text-info" ng-show="needsPort80(dnsCredentials.provider, dnsCredentials.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<div ng-show="provider === 'ami'">
<h3 class="text-center">Owner verification</h3>
<p class="has-error text-center" ng-show="error.ami">{{ error.ami }}</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error.ami) }">
<label class="control-label">EC2 Instance Id</label>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="i-0123456789abcdefg" ng-minlength="1" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p style="margin-top: 5px; font-size: 13px;">Provide the EC2 instance id to verify you have access to this server.</p>
</div>
<br/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">DNS Zone Name (Optional) <sup><a ng-href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.zoneName" name="zoneName" placeholder="Defaults to TLD" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="dnsCredentials.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider" ng-disabled="dnsCredentials.busy"></select>
</div>
<!-- IPv4 provider -->
<div class="form-group">
<label class="control-label">IPv4 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv4Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv4 Fixed -->
<div class="form-group" ng-show="ipv4Config.provider === 'fixed'">
<label class="control-label">IPv4 Address</label>
<input type="text" class="form-control" ng-model="ipv4Config.ip" name="ipv4" ng-required="ipv4Config.provider === 'fixed'">
</div>
<!-- IPv4 Network Interface -->
<div class="form-group" ng-show="ipv4Config.provider === 'network-interface'">
<label class="control-label">IPv4 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv4Config.ifname" name="ifname4" ng-required="ipv4Config.provider === 'network-interface'">
</div>
<!-- IPv6 provider -->
<div class="form-group">
<label class="control-label">IPv6 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv6 Fixed -->
<div class="form-group" ng-show="ipv6Config.provider === 'fixed'">
<label class="control-label">IPv6 Address</label>
<input type="text" class="form-control" ng-model="ipv6Config.ip" name="ipv6" ng-required="ipv6Config.provider === 'fixed'">
</div>
<!-- IPv6 Network Interface -->
<div class="form-group" ng-show="ipv6Config.provider === 'network-interface'">
<label class="control-label">IPv6 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv6Config.ifname" name="ifname6" ng-required="ipv6Config.provider === 'network-interface'">
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<form role="form" name="ownerForm" ng-submit="owner.submit()" novalidate>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
<h1>Welcome to Cloudron</h1>
<h3>Set up Admin Account</h3>
<p class="has-error text-center" ng-show="owner.error.generic">{{ owner.error.generic }}</p>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': ownerForm.displayName.$dirty && ownerForm.displayName.$invalid }">
<label class="control-label" for="inputDisplayName">Full Name</label>
<input type="text" class="form-control" ng-model="owner.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" ng-disabled="owner.busy" autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.email.$dirty && ownerForm.email.$invalid) || (!ownerForm.email.$dirty && owner.error.email) }">
<label class="control-label" for="inputEmail">Email <sup><a ng-href="https://docs.cloudron.io/installation/#admin-account" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="email" class="form-control" ng-model="owner.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" ng-disabled="owner.busy">
<small>A valid email is required for Let's Encrypt certificates. This email is local to your Cloudron. </small>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.username.$dirty && ownerForm.username.$invalid) || (!ownerForm.username.$dirty && owner.error.username) }">
<label class="control-label" for="inputUsername">Username</label>
<input type="text" class="form-control" ng-model="owner.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off" ng-disabled="owner.busy">
<small>{{ owner.error.username }}</small>
</div>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': ownerForm.password.$dirty && ownerForm.password.$invalid }">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" ng-model="owner.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off" ng-disabled="owner.busy" password-reveal>
<small><span ng-show="ownerForm.password.$dirty && ownerForm.password.$invalid">Password must be at least 8 characters</span> &nbsp;</small>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-success" ng-disabled="ownerForm.$invalid || owner.busy"><i class="fa fa-circle-notch fa-spin" ng-show="owner.busy"></i> Create Admin</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="row" ng-show="view === 'finished'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px 40px;">
<div class="row">
<div class="col-md-12 text-center">
<h1>Cloudron is ready to use</h1>
</div>
</div>
<p>
&nbsp; &nbsp; Before you start:
<ul class="fa-ul">
<li><i class="fa-li fa fa-users"></i>
<b>User management</b>: Cloudron has a central user directory. When installing an app,
you can set it up to authenticate against this directory.
</li>
<br/>
<li><i class="fa-li fa fa-envelope-open"></i>
<b>Email Configuration</b>: Apps are configured to send email based on the settings in the Email view.
This saves you the trouble of having to configure mail settings inside each app.
</li>
<br/>
<li><i class="fa-li fa fa-archive"></i>
<b>Backups</b>: Store your backups on storage services completely independent from your server.
You can use backups to seamlessly migrate your setup to another server.
</li>
<br/>
<li><i class="fa-li fa fa-birthday-cake"></i>
<b>Updates</b>: The Cloudron team tracks upstream releases and publishes app updates after testing.
Your apps are kept fresh &amp; secure.
</li>
</ul>
</p>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a class="btn btn-success" href="/">Proceed to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="text-center" ng-show="state === 'waitingForDnsSetup' || state === 'initialized'">
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
+2 -2
View File
@@ -14,7 +14,7 @@
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -147,7 +147,7 @@
<br/>
<h2>{{ 'setupAccount.success.title' | tr }}</h2>
<br/>
<a ng-href="dashboardUrl" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
<a href="/" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
</div>
</div>
</div>
+359
View File
@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Domain Setup</title>
<meta name="description" content="Cloudron Domain Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setupdns.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup' || state === 'waitingForBox'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard at my.{{dnsCredentials.domain}}.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="state === 'initialized'">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="dnsCredentialsForm" role="form" novalidate ng-submit="setDnsCredentials()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Domain Setup</h1>
<p class="has-error text-center" ng-show="error.setup">{{ error.setup }}</p>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<label class="control-label">Domain <sup><a ng-href="https://docs.cloudron.io/installation/#domain-setup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<div class="text-danger" ng-show="dnsCredentials.domain.indexOf('my.') === 0 && dnsCredentials.domain.length > 3">Are you sure about this domain? The dashboard will be at <b>my.{{ dnsCredentials.domain }}</b></div>
<p style="margin-top: 5px; font-size: 13px;">
Apps will be installed on subdomains of this domain. The dashboard will be available on the <b>my</b> subdomain. You can add more domains later.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<p class="has-error text-center" ng-show="error.dnsCredentials">{{ error.dnsCredentials }}</p>
<div class="form-group">
<label class="control-label">DNS Provider</label>
<select class="form-control" ng-model="dnsCredentials.provider" ng-options="a.value as a.name for a in dnsProvider" ng-disabled="dnsCredentials.busy" ng-change="setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="dnsCredentials.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-required="dnsCredentials.provider === 'gcdns'" ng-disabled="dnsCredentials.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.gandiApiKey.$dirty && dnsCredentialsForm.gandiApiKey.$invalid }" ng-show="dnsCredentials.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.gandiApiKey" name="gandiApiKey" placeholder="API Key" ng-required="dnsCredentials.provider === 'gandi'" ng-disabled="dnsCredentials.busy">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiKey.$dirty && dnsCredentialsForm.godaddyApiKey.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiKey" name="godaddyApiKey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiSecret.$dirty && dnsCredentialsForm.godaddyApiSecret.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiSecret" name="godaddyApiSecret" placeholder="API Secret" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupCustomerNumber.$dirty && dnsCredentialsForm.netcupCustomerNumber.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">Customer Number</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiKey.$dirty && dnsCredentialsForm.netcupApiKey.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiKey" name="netcupApiKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiPassword.$dirty && dnsCredentialsForm.netcupApiPassword.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Password</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiPassword" name="netcupApiPassword" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="dnsCredentials.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">Api Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="dnsCredentials.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="dnsCredentials.busy">
</div>
<div class="checkbox" ng-show="dnsCredentials.provider === 'cloudflare'">
<label>
<input type="checkbox" ng-model="dnsCredentials.cloudflareDefaultProxyStatus"> Enable proxying for new DNS records
<sup><a ng-href="https://docs.cloudron.io/domains/#cloudflare-dns" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComUsername.$dirty && dnsCredentialsForm.nameComUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComUsername" name="nameComUsername" placeholder="Name.com Username" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComToken.$dirty && dnsCredentialsForm.nameComToken.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComToken" name="nameComToken" placeholder="Name.com API Token" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapUsername.$dirty && dnsCredentialsForm.namecheapUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapUsername" name="namecheapUsername" placeholder="Namecheap Username" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapApiKey.$dirty && dnsCredentialsForm.namecheapApiKey.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="dnsCredentials.provider === 'namecheap'"><b>The server IP needs to be whitelisted for this API Key.</b></p>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapApiKey" name="namecheapApiKey" placeholder="Namecheap API Key" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Linode -->
<p class="form-group" ng-show="dnsCredentials.provider === 'linode'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.linodeToken" name="linodeToken" ng-required="dnsCredentials.provider === 'linode'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Bunny -->
<p class="form-group" ng-show="dnsCredentials.provider === 'bunny'">
<label class="control-label">Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- dnsimple -->
<p class="form-group" ng-show="dnsCredentials.provider === 'dnsimple'">
<label class="control-label">Access Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-required="dnsCredentials.provider === 'dnsimple'" ng-disabled="dnsCredentials.busy">
</p>
<!-- OVH -->
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">Endpoint</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="dnsCredentials.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'"></select>
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Consumer Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppKey" name="ovhAppKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppSecret" name="ovhAppSecret" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<!-- Porkbun -->
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.hetznerToken" name="hetznerToken" ng-required="dnsCredentials.provider === 'hetzner'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Vultr -->
<p class="form-group" ng-show="dnsCredentials.provider === 'vultr'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.vultrToken" name="vultrToken" ng-required="dnsCredentials.provider === 'vultr'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Wildcard -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'wildcard'">
<span>Set up A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}.</b> and <b>{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.</span>
</p>
<!-- Manual -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'manual'">
<span>Set up an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.<br/></span>
</p>
<p class="small text-info" ng-show="needsPort80(dnsCredentials.provider, dnsCredentials.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<div ng-show="provider === 'ami'">
<h3 class="text-center">Owner verification</h3>
<p class="has-error text-center" ng-show="error.ami">{{ error.ami }}</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error.ami) }">
<label class="control-label">EC2 Instance Id</label>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="i-0123456789abcdefg" ng-minlength="1" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p style="margin-top: 5px; font-size: 13px;">Provide the EC2 instance id to verify you have access to this server.</p>
</div>
<br/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">Zone Name (Optional) <sup><a ng-href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.zoneName" name="zoneName" placeholder="{{dnsCredentials.domain | zoneName}}" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="dnsCredentials.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider" ng-disabled="dnsCredentials.busy"></select>
</div>
<div class="form-group">
<label class="control-label">IP Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.provider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.provider === 'fixed'" ng-class="{ 'has-error': error.ipv4 }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.ipv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'fixed'">
<p class="has-error" ng-show="error.ipv4">{{ error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.provider === 'network-interface'" ng-class="{ 'has-error': error.ifname }">
<label class="control-label">Interface Name</label>
<input type="text" class="form-control" ng-model="sysinfo.ifname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'network-interface'">
<p class="has-error" ng-show="error.ifname">{{ error.ifname }}</p>
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title> Login to <%= title %> </title>
<link href="<%= icon %>" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/3rdparty/fontawesome/css/all.css"/>
</head>
<body>
<div class="layout-root">
<div class="layout-content">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= icon %>"/>
<br/>
<h1><small>{{ login.loginTo }}</small> <%= title %></h1>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 class="has-error" id="message"></h4>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form name="loginForm" onsubmit="return onLogin(event)">
<div class="form-group">
<label class="control-label" for="inputUsername">{{ login.username }}</label>
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
</div>
<div class="form-group">
<label class="control-label" for="inputPassword">{{ login.password }}</label>
<input type="password" class="form-control" name="password" id="inputPassword" required>
</div>
<div class="form-group">
<label class="control-label" for="inputTotpToken">{{ login.2faToken }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" value="">
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login"><i id="busyIndicator" class="hide fa fa-circle-notch fa-spin"></i> {{ login.signInAction }}</button>
</form>
<!-- <a ng-href="" class="hand" ng-click="showPasswordReset()">Reset password</a> -->
</div>
</div>
</div>
</div>
<script>
function onLogin(event) {
event.preventDefault();
var username = document.getElementById('inputUsername').value;
var password = document.getElementById('inputPassword').value;
var totpToken = document.getElementById('inputTotpToken').value;
document.getElementById('busyIndicator').classList.remove('hide');
fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
redirect: 'manual',
body: JSON.stringify({ username: username, password: password, totpToken: totpToken })
}).then(function (response) {
if (response.status === 401 || response.status === 403) {
document.getElementById('message').innerText = "{{ login.errorIncorrectCredentials }}"; // FIXME this needs proper escaping for translated strings, single quotes break easily!
document.getElementById('busyIndicator').classList.add('hide');
return;
}
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
window.location.href = search.redirect || '/';
});
return false;
}
// patch up for password reveal see dashboard/js/utils.js
var element = document.getElementById('inputPassword');
var svgEye = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye" class="svg-inline--fa fa-eye fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z"></path></svg>';
var svgEyeSlash = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye-slash" class="svg-inline--fa fa-eye-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path></svg>';
var eye = document.createElement('i');
eye.innerHTML = svgEyeSlash;
eye.style.width = '18px';
eye.style.height = '18px';
eye.style.position = 'relative';
eye.style.float = 'right';
eye.style.marginTop = '-24px';
eye.style.marginRight = '10px';
eye.style.cursor = 'pointer';
eye.addEventListener('click', function () {
if (element.type === 'password') {
element.type = 'text';
eye.innerHTML = svgEye;
} else {
element.type = 'password';
eye.innerHTML = svgEyeSlash;
}
});
element.parentNode.style.position = 'relative';
element.parentNode.insertBefore(eye, element.nextSibling);
</script>
</body>
</html>
+81 -213
View File
@@ -9,13 +9,13 @@ $brand-info: #3995b1 !default;
$brand-warning: #f0ad4e !default;
$brand-danger: #ff4c4c !default;
$body-bg: #f4f4f4;
$font-family-sans-serif: "Noto Sans", Helvetica, Arial, sans-serif;
$font-family-heading: "Noto Sans Light", Helvetica, Arial, sans-serif;
$body-bg: #E5E5E5;
$font-family-sans-serif: Roboto, Helvetica, Arial, sans-serif;
$font-family-heading: Roboto-Light, Helvetica, Arial, sans-serif;
$navbar-default-link-color: $brand-primary !default;
$navbar-default-link-color: #428BCA !default;
$navbar-default-link-hover-color: #62bdfc !default;
$navbar-default-link-active-color: #428BCA !default;
$navbar-default-link-active-color: #62bdfc !default;
$navbar-default-brand-color: #777 !default;
$btn-default-bg: transparent !default;
@@ -54,28 +54,27 @@ $state-danger-text: $brand-danger;
$state-danger-border: $brand-danger;
@import "bootstrap";
@import "3rdparty/noto-sans/index.css";
@font-face {
font-family: Roboto;
src: url(3rdparty/Roboto-Regular.ttf);
}
@font-face {
font-family: Roboto-Light;
src: url(3rdparty/Roboto-Light.ttf);
}
@font-face {
font-family: Roboto;
font-weight: 700;
src: url(3rdparty/Roboto-Bold.ttf);
}
// ----------------------------
// Bootstrap extension
// ----------------------------
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
font-family: $font-family-heading;
font-weight: 400;
}
.hide-mobile {
@media(max-width:767px) {
display: none;
}
}
.table-hover > tbody > tr:hover {
background-color: $body-bg;
}
.text-monospace {
font-family: $font-family-monospace;
}
@@ -112,7 +111,6 @@ h1, h2, h3, h4, h5, h6,
white-space: nowrap;
overflow: hidden;
max-width: 300px;
vertical-align: middle !important;
}
.wrap-table-cell {
@@ -135,10 +133,6 @@ select.form-control {
background-position-y: 5px;
}
.form-control {
box-shadow: none;
}
input[type="checkbox"], input[type="radio"] {
margin-top: 2px;
}
@@ -181,7 +175,16 @@ html, body {
}
.view-header-filter-bar {
text-align: right;
position: absolute;
right: 14px;
margin-top: 5px;
padding: 5px;
padding-top: 0;
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0,0,0,.15);
border-radius: 2px;
box-shadow: 0 6px 12px rgba(0,0,0,.175);
}
.view-header-search-bar {
@@ -275,11 +278,9 @@ html, body {
display: block;
width: 100%;
flex-grow: 0;
background-color: white;
border-color: white;
.navbar-collapse {
background-color: white;
background-color: #F8F8F8;
}
@media(min-width:768px) {
@@ -359,51 +360,6 @@ textarea {
// Apps view
// ----------------------------
.app-list {
width: 100%;
margin-top: 20px !important;
th {
white-space: nowrap;
}
.app-list-item {
.app-list-item-icon {
height: 32px;
}
.app-list-app-link-cell {
padding: 0;
}
.app-list-item-fqdn {
visibility: hidden;
color: $text-muted;
margin-left: 20px;
font-size: 12px;
}
&:hover .app-list-item-fqdn {
visibility: visible;
}
.app-list-app-link {
display: inline-block;
color: $text-dark;
padding: 8px;
&:hover {
text-decoration: none;
}
}
.app-list-item-progress {
height: 5px;
margin: 0 8px;
}
}
}
.app-grid {
display: flex;
flex-wrap: wrap;
@@ -538,28 +494,6 @@ textarea {
}
}
.app-checklist-badge {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: -12px;
top: -12px;
z-index: 2;
font-size: 14px;
height: 24px;
width: 24px;
color: white;
cursor: pointer;
background-color: $brand-danger;
border-radius: 34px;
transition: all 100ms ease-out;
&:hover {
transform: scale(1.4);
}
}
.app-postinstall-message {
max-height: 500px;
overflow-x: none;
@@ -572,6 +506,11 @@ textarea {
line-height: 1.4;
}
.app-info-meta {
margin-left: 4px;
color: $text-muted;
}
.app-info-icon {
float: left;
min-height: 64px;
@@ -607,7 +546,6 @@ multiselect {
cursor: pointer;
width: 64px;
height: 64px;
margin-bottom: 5px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
@@ -674,10 +612,6 @@ multiselect {
padding: 10px;
background-color: white;
@media (prefers-color-scheme: dark) {
background-color: #1c1c1c;
}
@media(min-width:768px) {
background-color: transparent;
width: auto;
@@ -701,26 +635,6 @@ multiselect {
}
}
.checklist-item {
padding: 8px;
border: none;
border-left: 2px solid rgb(255, 76, 76);
background-color: #ff000014;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.checklist-item-acknowledged {
border-left: 2px solid $brand-success;
background-color: transparent;
}
.checklist-item > span > p {
margin: 0;
}
// ----------------------------
// Mail view
// ----------------------------
@@ -734,7 +648,6 @@ multiselect {
.form-control {
display: inline-block;
width: 200px;
vertical-align: middle;
}
}
@@ -782,7 +695,7 @@ multiselect {
}
.card {
min-height: 558px;
min-height: 523px;
}
@media(min-width:768px) {
@@ -808,14 +721,11 @@ multiselect {
h1 {
margin-right: 10px;
margin-bottom: 0px;
margin-top: 16px;
padding: 4px 0;
line-height: 1;
line-height: 0.7;
font-size: 30px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden visible;
overflow: hidden;
a {
white-space: nowrap;
@@ -891,7 +801,7 @@ multiselect {
&:hover,
&:focus {
text-decoration: none;
background-color: #e9ebed;
background-color: #f3f3f3;
box-shadow: -4px 3px 5px -2px rgba(0,0,0,.1);
}
@@ -963,7 +873,7 @@ multiselect {
.appstore-toolbar-content {
display: flex;
margin: auto;
max-width: 1400px;
max-width: 1200px;
> * {
margin: 0 10px;
@@ -1001,7 +911,7 @@ multiselect {
margin: auto;
overflow: auto;
height: calc(100% - 65px); // offset navigation bar
max-width: 1400px;
max-width: 1200px;
h2 {
font-size: 20px;
@@ -1039,11 +949,6 @@ multiselect {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-bottom: 0px;
margin-top: 16px;
padding: 4px 0;
line-height: 1;
font-family: $font-family-sans-serif;
}
.appstore-item-content-tagline {
@@ -1053,18 +958,11 @@ multiselect {
}
.appstore-item-content-icon {
width: 90px;
min-width: 90px;
max-width: 90px;
width: 100px;
min-width: 100px;
max-width: 100px;
padding-left: 10px;
padding-right: 10px;
> .app-icon {
width: 70px;
height: 70px;
min-width: 70px;
min-height: 70px;
}
}
.appstore-category-link {
@@ -1186,10 +1084,6 @@ multiselect {
margin-bottom: 15px;
padding: 10px 15px;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
@media(max-width:767px) {
padding: 0;
}
}
.card-small {
@@ -1416,7 +1310,7 @@ select.purpose:invalid {
footer {
flex-grow: 0;
background-color: white;
background-color: #f8f8f8;
width: 100%;
color: #555;
max-height: 30px;
@@ -1542,39 +1436,6 @@ footer {
// Settings
// ----------------------------
.picture-edit-indicator {
position: absolute;
bottom: -4px;
right: -4px;
border-radius: 20px;
padding: 5px;
color: $text-dark;
background-color: white;
transition: all 250ms;
}
div:hover > .picture-edit-indicator {
color: white;
background: $brand-primary;
transform: scale(1.2);
}
.info-edit-indicator {
float: right;
border-radius: 20px;
padding: 5px;
color: $text-dark;
background-color: white;
transition: all 250ms;
cursor: pointer;
}
.info-edit-indicator:hover {
color: white;
background: $brand-primary;
transform: scale(1.2);
}
.settings-avatar {
position: relative;
cursor: pointer;
@@ -1592,6 +1453,23 @@ div:hover > .picture-edit-indicator {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(127, 127, 127 ,0.3);
background-image: url('/img/plus.png');
background-repeat: no-repeat;
background-position: center;
transition: all 150ms;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.settings-avatar-selector {
@@ -1750,7 +1628,6 @@ div:hover > .picture-edit-indicator {
.form-control {
display: inline-block;
width: 200px;
vertical-align: middle;
}
}
@@ -1765,7 +1642,6 @@ div:hover > .picture-edit-indicator {
.notification-item {
cursor: pointer;
padding: 10px 15px;
&:hover {
box-shadow: 0 2px 27px rgba(0,0,0,.1);
@@ -1800,6 +1676,23 @@ div:hover > .picture-edit-indicator {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(127, 127, 127 ,0.3);
background-image: url('/img/plus.png');
background-repeat: no-repeat;
background-position: center;
transition: all 150ms;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.branding-avatar-selector {
@@ -1844,23 +1737,6 @@ div:hover > .picture-edit-indicator {
}
}
.branding-background {
position: relative;
cursor: pointer;
width: 256px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
border: 1px solid gray;
border-radius: 3px;
img {
display: block;
width: 100%;
height: 100%;
}
}
// ----------------------------
// Tag Input
// ----------------------------
@@ -2339,14 +2215,6 @@ tag-input {
}
}
.app-list .app-list-item .app-list-app-link {
color: $textColor;
}
.app-list .app-list-item:hover .app-list-app-link {
color: white;
}
footer, .card, .app-configure-links div.active {
background-color: $backgroundDark;
}
+31 -84
View File
@@ -22,17 +22,13 @@
"auth": {
"sso": "Log ind med Cloudron-oplysninger",
"nosso": "Log ind med en dedikeret konto",
"email": "Log ind med din e-mailadresse",
"openid": "Log ind med Cloudron OpenID"
"email": "Log ind med din e-mailadresse"
},
"addAppAction": "Tilføj app",
"addAppproxyAction": "Tilføj app-proxy",
"addApplinkAction": "Tilføj app-link",
"filter": {
"clearAll": "Ryd alt"
},
"apps": {
"count": "Antal apps: {{ count }}"
}
},
"main": {
@@ -84,8 +80,7 @@
"justNow": "lige nu",
"yeserday": "I går",
"minutesAgo": "{{ m }} minutter siden",
"hoursAgo": "{{ h }} timer siden",
"never": "Aldrig"
"hoursAgo": "{{ h }} timer siden"
},
"navbar": {
"users": "Brugere"
@@ -170,10 +165,7 @@
"loginAction": "Login",
"createAccountAction": "Opret konto",
"switchToSignUpAction": "Har du ikke en konto endnu? Tilmeld dig",
"switchToLoginAction": "Har du allerede en konto? Log ind",
"setupWithTokenAction": "Opsætning",
"setupToken": "Opsætningstoken",
"titleToken": "Tilmeld dig med installationstoken"
"switchToLoginAction": "Har du allerede en konto? Log ind"
},
"title": "App Store",
"searchPlaceholder": "Søg efter alternativer som Github, Dropbox, Slack, Trello, …",
@@ -188,7 +180,7 @@
"users": {
"externalLdap": {
"title": "Tilslut en ekstern mappe",
"description": "Denne indstilling synkroniserer og godkender brugere og grupper fra en ekstern LDAP- eller Active Directory-server. Synkroniseringen køres med jævne mellemrum, men kan også udløses manuelt.",
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
@@ -203,15 +195,14 @@
"groupFilter": "Gruppefilter",
"groupnameField": "Groupname Felt",
"auth": "Auth",
"autocreateUsersOnLogin": "Opret automatisk brugere ved login",
"autocreateUsersOnLogin": "Opret automatisk brugere, når de logger ind på Cloudron",
"showLogsAction": "Vis logs",
"syncAction": "Synkroniser",
"configureAction": "Konfigurer",
"bindPassword": "Bind adgangskode (valgfrit)",
"errorSelfSignedCert": "Serveren bruger et ugyldigt eller selvsigneret certifikat.",
"providerOther": "Andre",
"providerDisabled": "Deaktiveret",
"disableWarning": "Godkendelseskilden for alle eksisterende brugere bliver nulstillet til at godkende mod den lokale adgangskodedatabase."
"providerDisabled": "Deaktiveret"
},
"addUserDialog": {
"sendInviteCheckbox": "Send en e-mail med en invitation nu",
@@ -236,9 +227,7 @@
"primaryEmail": "Primær e-mail",
"errorDisplayNameRequired": "Navn er påkrævet",
"activeCheckbox": "Brugeren er aktiv",
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen",
"external2FA": "2FA-opsætning styres af ekstern godkendelseskilde",
"ldapGroups": "LDAP-grupper"
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen"
},
"invitationDialog": {
"descriptionLink": "Kopier link til invitation",
@@ -266,11 +255,10 @@
"description": "Cloudron kan fungere som en central brugerkatalogserver for eksterne programmer.",
"enabled": "Aktiveret",
"ipRestriction": {
"description": "Begræns adgang til Directory Server til specifikke IP'er eller områder. Linjer, der starter med <code>#</code>, behandles som kommentarer.",
"description": "Mappeserveren kan begrænses til bestemte IP'er eller områder.",
"placeholder": "Linjeadskilt IP-adresse eller undernet",
"label": "Begræns adgang"
},
"cloudflarePortWarning": "Cloudflare-proxying skal deaktiveres på dashboard-domænet for at få adgang til LDAP-serveren"
}
},
"userImportDialog": {
"description": "Upload en JSON- eller CSV-fil med det skema, der er beskrevet i vores <a href=\"{{ docsLink }}\" target=\"_blank\">dokumentation</a>",
@@ -494,10 +482,7 @@
"changeEmail": {
"title": "Ændre primær e-mailadresse",
"errorEmailInvalid": "E-mail-adressen er ikke gyldig",
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet",
"email": "Ny e-mailadresse",
"password": "Adgangskode til bekræftelse",
"errorWrongPassword": "Forkert adgangskode"
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet"
},
"changeDisplayName": {
"title": "Ændre dit visningsnavn",
@@ -624,7 +609,7 @@
},
"check": {
"noop": "Cloudron-backups er deaktiveret. Sørg for, at der tages backup af denne server ved hjælp af alternative midler. Se https://docs.cloudron.io/backups/#storage-providers for flere oplysninger.",
"sameDisk": "Sikkerhedskopierne ligger i øjeblikket på den samme disk som Cloudron selv. Hvis disken fyldes op med disse sikkerhedskopier, vil Cloudron ikke fungere. En diskfejl kan også føre til fuldstændigt datatab. Se https://docs.cloudron.io/backups/#storage-providers for at gemme sikkerhedskopier på et eksternt sted."
"sameDisk": "Cloudron-backups er i øjeblikket på den samme disk som Cloudron-serverinstansen. Dette er farligt og kan føre til fuldstændigt tab af data, hvis disken fejler. Se https://docs.cloudron.io/backups/#storage-providers for lagring af sikkerhedskopier på en ekstern placering."
},
"title": "Sikkerhedskopiering",
"logs": {
@@ -657,9 +642,7 @@
"logo": "Logo",
"changeLogo": {
"title": "Vælg Cloudron Avatar"
},
"backgroundImage": "Baggrundsbillede af login-side",
"clearBackgroundImage": "Klar"
}
},
"emails": {
"domains": {
@@ -788,7 +771,7 @@
"ip": {
"interfaceDescription": "Liste over tilgængelige enheder på serveren med:",
"title": "IP-adresse",
"description": "Cloudron bruger denne IPv4-adresse til at oprette DNS A-poster.",
"description": "Cloudron bruger denne IP-adresse, når der oprettes DNS-poster.",
"provider": "Udbyder",
"interface": "Navn på netværksgrænseflade",
"configure": "Konfigurer",
@@ -812,7 +795,7 @@
},
"title": "Netværk",
"configureIp": {
"title": "Konfigurer IPv4-provider",
"title": "Konfigurer IP-provider",
"providerGenericDescription": "Serverens offentlige IP-adresse registreres automatisk."
},
"ipv4": {
@@ -855,12 +838,14 @@
},
"settings": {
"timezone": {
"description": "Den aktuelle tidszoneindstilling er <b>{{ timeZone }}</b>. Denne indstilling bruges til at planlægge backup- og opdateringsopgaver. Tidsstempler i brugergrænsefladen vises altid i browserens tidszone.",
"description": "Den aktuelle tidszoneindstilling er <b>{{{{ timeZone }}}</b>.\nDenne indstilling bruges til planlægning af backup- og opdateringsopgaver.",
"title": "Tidszone"
},
"updates": {
"updateAvailableAction": "Opdatering tilgængelig",
"title": "Opdateringer",
"autoUpdateDisabled": "Automatisk opdatering af platformen og apps er<b>deaktiveret</b>.",
"currentSchedule": "Den nuværende tidsplan for automatisk opdatering af platform og apps er",
"version": "Platform version",
"showLogsAction": "Vis logs",
"changeScheduleAction": "Ændre tidsplan",
@@ -955,11 +940,7 @@
"disableAction": "Deaktivere SSH-støtteadgang",
"enableAction": "Aktiver SSH-støtteadgang"
},
"title": "Støtte",
"help": {
"title": "Hjælp",
"description": "Brug venligst følgende ressourcer til hjælp og support:\n* [Cloudron Forum]({{ forumLink }}) - Brug venligst de support- og app-specifikke kategorier til spørgsmål.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
"title": "Støtte"
},
"system": {
"diskUsage": {
@@ -983,19 +964,7 @@
"title": "System Memory",
"graphSubtext": "Kun apps, der bruger mere end {{ threshold }} af memory, vises"
},
"selectPeriodLabel": "Vælg periode",
"info": {
"platformVersion": "Platformsversion",
"title": "Info",
"vendor": "Leverandør",
"product": "Produtk",
"memory": "Memory",
"uptime": "Driftstid",
"activationTime": "Cloudrons skabelsestidspunkt"
},
"graphs": {
"title": "Diagrammer"
}
"selectPeriodLabel": "Vælg periode"
},
"domains": {
"renewCerts": {
@@ -1056,13 +1025,7 @@
"porkbunSecretapikey": "Hemmelig API-nøgle",
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
"porkbunApikey": "API-nøgle",
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Adgangstoken",
"ovhEndpoint": "Endepunkt",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
"bunnyAccessKey": "Bunny Access Key"
},
"title": "Domæner og certs",
"addDomain": "Tilføj domæne",
@@ -1126,8 +1089,7 @@
"copy": "Kopier",
"clear": "Klar",
"pasteInfo": "Brug Ctrl+v til at indsætte for at indsætte"
},
"uploadTo": "Upload til {{ path }}"
}
},
"filemanager": {
"newFileDialog": {
@@ -1159,8 +1121,7 @@
"renameDialog": {
"title": "Omdøb {{ fileName }}",
"newName": "Nyt navn",
"rename": "Omdøb",
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?"
"rename": "Omdøb"
},
"extractDialog": {
"title": "Udpakning af {{ fileName }}",
@@ -1317,7 +1278,7 @@
},
"enableEmailDialog": {
"description": "Dette vil konfigurere Cloudron til at modtage e-mails for<b>{{ domain }}</b>Se dokumentationen for åbning af de <a href=\"{{{ requiredPortsDocsLink }}\" target=\"_blank\">forpligtede porte</a> for Cloudron Email.",
"cloudflareInfo": "Mailserverens domæne <code>{{ adminDomain }}</code> administreres af Cloudflare. Kontrollér, at Cloudflare-proxy er deaktiveret for <code>{{ mailFqdn }}</code> og indstillet til <code>kun DNS</code>. Dette er nødvendigt, fordi Cloudflare ikke proxy'er e-mail.",
"cloudflareInfo": "Domænet <code>{{{ adminDomain }}</code> administreres af Cloudflare. Kontroller venligst, at Cloudflare-proxying er deaktiveret for <code>{{{ mailFqdn }}</code> og indstillet til <code>Kun DNS</code>. Dette er påkrævet, fordi Cloudflare ikke giver proxy for e-mail.",
"title": "Aktiver e-mail for {{ domain }}?",
"noProviderInfo": "Der er ikke oprettet nogen DNS-udbyder. De DNS-poster, der er anført i fanen Status, skal oprettes manuelt.",
"setupDnsCheckbox": "Opsæt Mail DNS-poster nu",
@@ -1470,7 +1431,7 @@
},
"memory": {
"title": "Memory graense",
"description": "Maksimal arbejdshastighed, som appen kan bruge",
"description": "Cloudron tildeler 50 % af denne værdi som RAM og 50 % som swap.",
"error": "Kan ikke indstille memory limit, prøv mindre.",
"resizeAction": "Ændre størrelse"
}
@@ -1531,12 +1492,11 @@
"packageVersion": "Pakkeversion",
"lastUpdated": "Sidst opdateret",
"checkForUpdatesAction": "Tjek for opdateringer",
"customAppUpdateInfo": "Automatisk opdatering er ikke tilgængelig for brugerdefinerede apps.",
"updateAvailableAction": "Opdatering tilgængelig",
"installedAt": "Installeret på"
"customAppUpdateInfo": "Opdateringer er ikke tilgængelige for brugerdefinerede apps",
"updateAvailableAction": "Opdatering tilgængelig"
},
"auto": {
"description": "Cloudron tjekker med jævne mellemrum <a href=»{{ appStoreLink }}« target=»_blank«>App Store</a> for opdateringer.",
"description": "Cloudron kontrollerer jævnligt App Store for opdateringer. Hvis du deaktiverer automatiske opdateringer, skal du sørge for at anvende opdateringerne manuelt.",
"title": "Automatiske opdateringer",
"enabled": "Automatiske opdateringer er i øjeblikket aktiveret.",
"disabled": "Automatiske opdateringer er i øjeblikket deaktiveret.",
@@ -1609,8 +1569,7 @@
"openAction": "Åbn {{ app }}",
"firstTimeTitle": "Første gang du bruger det",
"firstTimeCollapseHeader": "Første gangs opsætningsvejledning",
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app.",
"checklist": "Administrativ tjekliste"
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app."
},
"restoreDialog": {
"warning": "Alle data, der er genereret mellem nu og den sidst kendte sikkerhedskopi, vil uigenkaldeligt gå tabt. Det anbefales at oprette en sikkerhedskopi af de aktuelle data, før du forsøger at gendanne dem.",
@@ -1759,12 +1718,6 @@
"title": "Redis-konfiguration",
"enable": "Konfigurer appen til at bruge Redis",
"disable": "Deaktiver Redis"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrative noter"
}
}
},
"passwordReset": {
@@ -1868,11 +1821,7 @@
"mountStatus": "Status for montering",
"type": "Type",
"localDirectory": "Lokal vejviser",
"remountActionTooltip": "Genmonter",
"editVolumeDialog": {
"title": "Rediger volumen {{ name }}"
},
"editActionTooltip": "Rediger volumen"
"remountActionTooltip": "Genmonter"
},
"newLoginEmail": {
"topic": "Vi har bemærket et nyt login på din Cloudron-konto.",
@@ -1910,8 +1859,7 @@
"signInAction": "Log ind",
"resetPasswordAction": "Nulstil adgangskode",
"errorIncorrect2FAToken": "2FA-token er ugyldig",
"errorInternal": "Intern fejl, prøv igen senere",
"loginWith": "Log ind med Cloudron"
"errorInternal": "Intern fejl, prøv igen senere"
},
"lang": {
"en": "English",
@@ -1926,8 +1874,7 @@
"es": "Spansk",
"ru": "Russisk",
"pt": "Portugisisk",
"da": "Dansk",
"id": "Indonesisk"
"da": "Dansk"
},
"supportConfig": {
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."
+50 -148
View File
@@ -22,17 +22,13 @@
"auth": {
"nosso": "Die App verwendet eine eigene Benutzerverwaltung",
"email": "Mit E-Mail-Adresse anmelden",
"sso": "Mit Cloudron Zugangsdaten anmelden",
"openid": "Mit Cloudron OpenID anmelden"
"sso": "Mit Cloudron Zugangsdaten anmelden"
},
"addAppAction": "App hinzufügen",
"addAppproxyAction": "App Proxy hinzufügen",
"addApplinkAction": "App Link hinzufügen",
"filter": {
"clearAll": "Alles löschen"
},
"apps": {
"count": "Appanzahl: {{ count }}"
}
},
"main": {
@@ -55,8 +51,7 @@
},
"action": {
"logs": "Logs",
"reboot": "Neustarten",
"showLogs": "Zeige Logs"
"reboot": "Neustarten"
},
"pagination": {
"perPageSelector": "Zeige {{ n }} pro Seite",
@@ -84,8 +79,7 @@
"justNow": "gerade eben",
"yeserday": "Gestern",
"minutesAgo": "vor {{ m }} Minuten",
"hoursAgo": "vor {{ h }} Stunden",
"never": "Nie"
"hoursAgo": "vor {{ h }} Stunden"
},
"disableAction": "Deaktivieren",
"enableAction": "Aktivieren",
@@ -95,18 +89,16 @@
},
"statusDisabled": "Deaktiviert",
"loadingPlaceholder": "Laden",
"settings": "Einstellungen",
"saveAction": "Speichern"
"settings": "Einstellungen"
},
"network": {
"title": "Netzwerk",
"dyndns": {
"title": "Dynamischer DNS",
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft.",
"showLogsAction": "Zeige Logs"
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft."
},
"configureIp": {
"title": "IPv4-Anbieter konfigurieren",
"title": "IP-Anbieter konfigurieren",
"providerGenericDescription": "Die öffentliche IP-Adresse des Servers wird automatisch erkannt."
},
"firewall": {
@@ -120,12 +112,12 @@
"blocklist": "{{ blockCount }} IP(s) sind gesperrt"
},
"ip": {
"description": "Cloudron verwendet diese IPv4-Adresse beim Einrichten von DNS A Einträgen.",
"description": "Cloudron verwendet diese IP-Adresse beim Einrichten von DNS-Einträgen.",
"provider": "Anbieter",
"interface": "Name der Netzwerkschnittstelle",
"configure": "Konfigurieren",
"interfaceDescription": "Verfügbare Netzwerkgeräte auf dem Server anzeigen mit:",
"title": "IPv4",
"title": "IP-Adresse",
"detected": "ermittelt",
"address": "IP Adresse"
},
@@ -139,13 +131,7 @@
},
"ipv4": {
"address": "IPv4 Adresse"
},
"trustedIps": {
"description": "HTTP header, von übereinstimmenden IP-Adressen, wird vertraut",
"summary": "{{ trustCount }} IPs vertrauen",
"title": "Konfiguriere vertrauenswürdige IPs"
},
"trustedIpRanges": "Vertrauenswürdige IPs & IP-Bereichen "
}
},
"settings": {
"title": "Einstellungen",
@@ -167,11 +153,13 @@
"updates": {
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"title": "Aktualisierungen",
"currentSchedule": "Die Einstellungen für die automatische Aktualisierung für System und Anwendungen lautet:",
"version": "Systemversion",
"changeScheduleAction": "Zeitplan ändern",
"stopUpdateAction": "Aktualisierung abbrechen",
"updateAvailableAction": "Aktualisierung verfügbar",
"showLogsAction": "Logfiles anzeigen"
"showLogsAction": "Logfiles anzeigen",
"autoUpdateDisabled": "Die automatische Aktualisierung des Systems und der Anwendungen ist <b>deaktiviert</b>."
},
"appstoreAccount": {
"title": "Cloudron.io-Konto",
@@ -179,7 +167,7 @@
"description": "Ein Cloudron.io-Konto wird für den Zugriff auf den App-Store und die Verwaltung des Abonnements verwendet.",
"subscriptionSetupAction": "Abonnement einrichten",
"cloudronId": "Cloudron-ID",
"subscriptionChangeAction": "Abonnement verwalten",
"subscriptionChangeAction": "Abonnement ändern",
"setupAction": "Konto einrichten",
"subscription": "Abonnement-Typ",
"subscriptionReactivateAction": "Abonnement reaktivieren",
@@ -228,7 +216,7 @@
"configureAction": "Einrichten",
"syncAction": "Synchronisieren",
"showLogsAction": "Zeige Logs",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden auf der Cloudron-Instanz",
"auth": "Authentifizierung",
"groupnameField": "Gruppennamen Feld",
"groupFilter": "Gruppenfilter",
@@ -242,11 +230,10 @@
"provider": "Anbieter",
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
"subscriptionRequiredAction": "Abonnenement jetzt abschließen",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Die Synchronisierung läuft automatisch, kann aber auch manuell gestartet werden.",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Passwörter beim Anmelden werden immer durch den externen Server validiert. Die Synchronisierung läuft nicht automatisch, sondern muss manuell gestartet werden.",
"title": "Verbinde ein externes Verzeichnis",
"providerOther": "Sonstige",
"providerDisabled": "Deaktiviert",
"disableWarning": "Die Authentifizierungsmethode von allen Usern wird auf die lokale Datenbank zurückgesetzt."
"providerDisabled": "Deaktiviert"
},
"settings": {
"saveAction": "Speichern",
@@ -355,9 +342,7 @@
"username": "Username",
"fullName": "Vollständiger Name",
"fallbackEmailPlaceholder": "Optional. Falls nicht gesetzt wird die Primäre E-Mail benutzt",
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden",
"external2FA": "2FA Einstellungen werden von der externen Authentifikationsmethode verwaltet",
"ldapGroups": "LDAP Gruppen"
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden"
},
"addUserDialog": {
"addUserAction": "User hinzufügen",
@@ -401,13 +386,12 @@
},
"description": "Cloudron kann als zentraler Benutzerverzeichnis-Server für externe Anwendungen fungieren.",
"ipRestriction": {
"description": "Der Verzeichnisserver muss auf bestimmte IPs oder Bereiche beschränkt werden. Zeilen, die mit <code>#</code> beginnen werden als Kommentare gewertet.",
"description": "Der Verzeichnisserver kann auf bestimmte IPs oder Bereiche beschränkt werden.",
"label": "Zugriff beschränken",
"placeholder": "Zeilen separierte IP Adresse oder Subnetz"
},
"enabled": "Aktiviert",
"title": "Verzeichnis Server",
"cloudflarePortWarning": "Cloudflare Proxying für die Dashboarddomäne muss deaktiviert sein um den LDAP Server zu erreichen"
"title": "Verzeichnis Server"
},
"invitationNotification": {
"title": "Einladungslink versendet",
@@ -516,10 +500,7 @@
"changeEmail": {
"errorEmailRequired": "Eine gültige E-Mail-Adresse ist erforderlich",
"errorEmailInvalid": "Die E-Mail-Adresse ist nicht gültig",
"title": "Primäre E-Mail-Adresse ändern",
"email": "Neue E-Mail-Adresse",
"password": "Passwort zur Bestätigung",
"errorWrongPassword": "Falsches Passwort"
"title": "Primäre E-Mail-Adresse ändern"
},
"loginTokens": {
"logoutAll": "Von allen abmelden",
@@ -548,15 +529,14 @@
},
"changeBackgroundImage": {
"title": "Hintergrundbild setzen"
},
"enable2FANotAvailable": "Für externe User nicht verfügbar"
}
},
"emails": {
"title": "E-Mail",
"settings": {
"spamFilter": "Spamfilter",
"maxMailSize": "Maximalgröße einer E-Mail",
"location": "Domäne des Mail-Servers",
"location": "Standort des Mail-Servers",
"info": "Die Einstellungen sind global und werden bei allen Domains verwendet.",
"title": "Einstellungen",
"spamFilterOverview": "{{ blacklistCount }} Adressen sind auf der Blockliste.",
@@ -567,8 +547,7 @@
"solrNotRunning": "Inaktiv",
"solrRunning": "Aktiv",
"aclOverview": "{{ dnsblZonesCount }} DNSBL Zonen",
"acl": "Postfachberechtigungen",
"virtualAllMail": "\"All Mail\" Ordner"
"acl": "Postfachberechtigungen"
},
"domains": {
"testEmailTooltip": "Test E-Mail senden",
@@ -617,7 +596,7 @@
},
"changeDomainDialog": {
"locationPlaceholder": "Leer lassen, um die Haupt-Domäne zu verwenden",
"description": "Dies zieht den E-Mail Server auf die neue Domäne um.",
"description": "Cloudron nimmt die notwendigen DNS-Änderungen in allen Domänen vor und startet den Mail-Server neu. Desktop & Mobile E-Mail-Clients müssen neu konfiguriert werden, um diese neue Adresse als IMAP- und SMTP-Server zu verwenden.",
"location": "Adresse",
"title": "E-Mail-Server Standort ändern",
"manualInfo": "Manuell einen A-Eintrag für {{ Domain }} zur öffentlichen IP dieses Cloudrons hinzufügen"
@@ -668,10 +647,6 @@
},
"action": {
"queue": "Warteschlange"
},
"changeVirtualAllMailDialog": {
"description": "Der \"All Mail\" Ordner ist ein einziger Ordner, welcher alle E-Mails des Posteingangs beinhaltet. Dieser Ordner unterstützt mit E-Mail Anwendungen, welche keine rekursive Suche anbieten.",
"title": "\"All Mail\" Ordner"
}
},
"support": {
@@ -694,8 +669,7 @@
"report": "Meldung",
"subscriptionRequiredDescription": "Antworten auf die häufigsten Fragen sind in der <a href=\"{{ supportViewLink }}\" target=\"_blank\">Dokumentation</a> verfügbar. Unser <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a> bietet einen Platz in die Community einzusteigen und sich auszutauschen.",
"emailVerifyAction": "Jetzt verifizieren",
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen.",
"typeBilling": "Problem mit Rechnung"
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen."
},
"remoteSupport": {
"title": "Fernwartung",
@@ -704,10 +678,6 @@
"subscriptionRequired": "Fernwartung ist nur im Abo verfügbar.",
"description": "Diese Option aktivieren, um Mitarbeitenden aus dem Support zu erlauben, sich über SSH mit diesem Server zu verbinden.",
"disableAction": "Zugang zur SSH-Unterstützung deaktivieren"
},
"help": {
"description": "Bitte die folgenden Resourcen für Hilfe und Support:\n* [Cloudron Forum]({{ forumLink }}) - Bitte die Support und App spezifischen Kategorien nutzen .\n* [Cloudron Doku & Wissensdatenbank]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n",
"title": "Hilfe"
}
},
"eventlog": {
@@ -773,18 +743,12 @@
"cloudflareDefaultProxyStatus": "Proxying für neue DNS-Einträge aktivieren",
"porkbunSecretapikey": "Geheimer API-Schlüssel",
"porkbunApikey": "API-Schlüssel",
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Access Token",
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
"bunnyAccessKey": "Bunny Access Key"
},
"changeDashboardDomain": {
"title": "Die Dashboard-Domäne ändern",
"showLogsAction": "Logfiles anzeigen",
"description": "Dadurch wird das Dashboard in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"description": "Dadurch werden das Dashboard und der E-Mail-Server in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"changeAction": "Domäne ändern",
"cancelAction": "Abbrechen"
},
@@ -812,8 +776,7 @@
"tooltipWellKnown": ".well-known Pfade setzen",
"domainWellKnown": {
"title": ".well-known Pfade von {{ domain }}"
},
"count": "Domänenanzahl: {{ count }}"
}
},
"notifications": {
"title": "Benachrichtigungen",
@@ -844,19 +807,7 @@
"title": "CPU-Auslastung",
"graphSubtext": "Es werden nur Anwendungen angezeigt, die mehr als {{ threshold }} an Rechenleistung benötigen"
},
"selectPeriodLabel": "Zeitraum auswählen",
"info": {
"platformVersion": "Plattform Version",
"title": "Info",
"vendor": "Anbieter",
"product": "Produkt",
"memory": "Arbeitsspeicher",
"uptime": "Betriebszeit",
"activationTime": "Cloudron Aktivierungszeit"
},
"graphs": {
"title": "Graphen"
}
"selectPeriodLabel": "Zeitraum auswählen"
},
"backups": {
"title": "Datensicherung",
@@ -982,8 +933,7 @@
"tooltip": "Dadurch bleiben auch die Mail- und {{ appsLength }} App-Backups erhalten.",
"description": "Backup unabhängig von der Aufbewahrungsrichtlinie beibehalten"
},
"label": "Label",
"remotePath": "Remote Pfad"
"label": "Label"
}
},
"appstore": {
@@ -1002,10 +952,7 @@
"email": "E-Mail",
"description": "Dieses Konto gibt Zugriff zum App-Store und Aboverwaltung",
"titleLogin": "Bei Cloudron.io anmelden",
"titleSignUp": "Bei Cloudron.io registrieren",
"setupWithTokenAction": "Registrieren",
"setupToken": "Setup Token",
"titleToken": "Mit Setup Token registrieren"
"titleSignUp": "Bei Cloudron.io registrieren"
},
"appNotFoundDialog": {
"description": "Die Anwendung <b>{{ appId }}</b> mit der Version <b>{{ version }}</b> existiert nicht.",
@@ -1105,9 +1052,7 @@
"title": "Fußzeile"
},
"logo": "Logo",
"cloudronName": "Name der Cloudron-Instanz",
"backgroundImage": "Hintergrundbild der Login-Seite",
"clearBackgroundImage": "Löschen"
"cloudronName": "Name der Cloudron-Instanz"
},
"login": {
"password": "Passwort",
@@ -1116,10 +1061,7 @@
"2faToken": "2FA-Token (wenn aktiviert)",
"loginTo": "Anmeldung bei",
"signInAction": "Anmelden",
"resetPasswordAction": "Passwort zurücksetzen",
"loginWith": "Mit Cloudron anmelden",
"errorIncorrect2FAToken": "2FA Token ist ungültig",
"errorInternal": "Interner Fehler, später nochmals versuchen"
"resetPasswordAction": "Passwort zurücksetzen"
},
"welcomeEmail": {
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
@@ -1228,7 +1170,7 @@
"enableEmailDialog": {
"description": "Dies wird Cloudron so konfigurieren, dass E-Mails für <b>{{ domain }}</b> empfangen werden. Die Dokumentation zum Öffnen der <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">erforderlichen Ports</a> für Cloudron E-Mail lesen.",
"noProviderInfo": "Es ist kein DNS-Anbieter eingerichtet. Die in der Registerkarte Status aufgeführten DNS-Einträge müssen manuell eingerichtet werden.",
"cloudflareInfo": "Die E-Mail Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"cloudflareInfo": "Die Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"enableAction": "Aktivieren",
"title": "E-Mail für {{ domain }} aktivieren?",
"setupDnsCheckbox": "DNS-Einträge für E-Mail jetzt einrichten",
@@ -1376,8 +1318,7 @@
"renameDialog": {
"newName": "Neuer Name",
"title": "{{ fileName }} umbennen",
"rename": "Umbenennen",
"reallyOverwrite": "Eine Datei mit diesem Namen existiert bereits. Diese Datei überschreiben?"
"rename": "Umbenennen"
},
"extractDialog": {
"title": "Extrahieren von {{ fileName }}",
@@ -1439,19 +1380,7 @@
},
"status": {
"restartingApp": "Die Anwendung wird neugestartet"
},
"uploader": {
"uploading": "Hochladen",
"exitWarning": "Aktuell werden noch Dateien hochgeladen. Wirklich schließen?"
},
"textEditor": {
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"save": "Speichern"
},
"extractionInProgress": "Entpacken läuft",
"pasteInProgress": "Einfügen läuft",
"deleteInProgress": "Löschen läuft"
}
},
"passwordReset": {
"usernameOrEmail": "Username oder E-Mail-Adresse",
@@ -1527,14 +1456,14 @@
"logsActionTooltip": "Logfiles",
"resources": {
"cpu": {
"setAction": "Skalieren",
"title": "CPU Limit",
"description": "Maximale CPU Prozente, die dieser App zur Verfügung stehen"
"setAction": "Festlegen",
"title": "CPU-Freigabe",
"description": "Prozent der CPU-Zeit, wenn das System unter hoher Last steht."
},
"memory": {
"resizeAction": "Größe ändern",
"title": "Speicherlimit",
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht",
"description": "Cloudron weist 50% dieses Wertes als RAM und 50% als Swap zu.",
"error": "Speicherlimit nicht einstellbar. Weniger versuchen."
}
},
@@ -1601,7 +1530,7 @@
},
"uninstall": {
"backupWarning": "Anwendungs-Backups werden nicht entfernt und auf der Grundlage der Backup-Richtlinie bereinigt. Diese Anwendung kann aus einem bestehenden App-Backup mit den folgenden <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">Schritten</a> wiederhergestellt werden.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle zugehörigen Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"title": "Deinstallieren",
"uninstallAction": "Deinstallieren"
}
@@ -1617,12 +1546,12 @@
},
"updates": {
"auto": {
"enableAction": "Aktivieren",
"enableAction": "Automatische Aktualisierungen aktivieren",
"disabled": "Die automatische Aktualisierung ist deaktiviert.",
"enabled": "Die automatische Aktualisierung ist aktiviert.",
"title": "Automatische Aktualisierungen",
"description": "Cloudron fragt regelmäßig den App-Store nach Aktualisierungen ab. Wenn automatisches Aktualisieren deaktiviert ist, bitte sicherstellen, dass manuell nach Aktualisierungen gesucht wird.",
"disableAction": "Deaktivieren"
"disableAction": "Automatische Aktualisierungen deaktivieren"
},
"info": {
"updateAvailableAction": "Aktualisierung verfügbar",
@@ -1633,8 +1562,7 @@
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"packageVersion": "Paket-Version",
"repository": "Paket-Repository",
"installedAt": "Installationszeitpunkt"
"repository": "Paket-Repository"
},
"noUpdates": "Keine neuen Updates verfügbar"
},
@@ -1681,8 +1609,7 @@
"dataDirPlaceholder": "Leer lassen, um Systemvorgabe zu verwenden",
"description": "Wenn dem Server der Speicherplatz ausgeht, kann durch Hinzufügen einer <a href=\"/#/volumes\">externen Festplatte</a>, die Daten der Anwendung dorthin verschoben werden.",
"moveAction": "Daten verschieben",
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }}).",
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }})."
},
"mounts": {
"title": "Mounts",
@@ -1832,31 +1759,12 @@
},
"addApplinkDialog": {
"title": "Link zur externen Anwendung hinzufügen"
},
"redis": {
"disable": "Redis deaktivieren",
"title": "Redis Konfiguration",
"enable": "Die App mit Redis vorkonfigurieren"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrator Notizen"
}
},
"turn": {
"enable": "App für den internen TURN Server konfigurieren",
"disable": "TURN Server dieser App nicht automatisch konfigurieren.",
"title": "TURN Einstellungen"
},
"servicesTabTitle": "Dienste"
}
},
"logs": {
"download": "Vollständige Logfiles herunterladen",
"title": "Logfiles",
"clear": "Anzeige löschen",
"notFoundError": "Task oder App existiert nicht",
"logsGoneError": "Logdatei(n) nicht gefunden"
"clear": "Anzeige löschen"
},
"lang": {
"en": "Englisch",
@@ -1870,8 +1778,7 @@
"es": "Spanisch",
"ru": "Russisch",
"pt": "Portugiesisch",
"da": "Dänisch",
"id": "Indonesian"
"da": "Dänisch"
},
"volumes": {
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
@@ -1908,11 +1815,7 @@
"mountStatus": "Einhängestatus",
"localDirectory": "Lokales Verzeichnis",
"type": "Typ",
"remountActionTooltip": "Datenträger neu einhängen",
"editVolumeDialog": {
"title": "Datenträger {{ name }} konfigurieren"
},
"editActionTooltip": "Datenträger konfigurieren"
"remountActionTooltip": "Datenträger neu einhängen"
},
"lang.ja": "Japanisch",
"newLoginEmail": {
@@ -1966,6 +1869,5 @@
"newClient": "Neuer Client",
"empty": "Noch keine Clienten erstellt"
}
},
"automation": "Automatisierung"
}
}
+34 -53
View File
@@ -30,9 +30,6 @@
"addApplinkAction": "Add App Link",
"filter": {
"clearAll": "Clear All"
},
"apps": {
"count": "Total apps: {{ count }}"
}
},
"main": {
@@ -84,8 +81,7 @@
"justNow": "just now",
"yeserday": "Yesterday",
"minutesAgo": "{{ m }} minutes ago",
"hoursAgo": "{{ h }} hours ago",
"never": "Never"
"hoursAgo": "{{ h }} hours ago"
},
"navbar": {
"users": "Users"
@@ -521,7 +517,7 @@
"title": "Backups",
"location": {
"title": "Location",
"description": "A complete backup of your system is saved to the storage location with the configured format.",
"description": "Cloudron makes a complete backup of your system at the configured location.",
"disabledList": "The following apps have automatic backups disabled:",
"provider": "Provider",
"location": "Location",
@@ -532,7 +528,7 @@
},
"schedule": {
"title": "Schedule and Retention",
"description": "A complete backup of the system is created based on the specified Schedule in the <a href=\"/#/settings\">System Time Zone</a>. Old backups are removed based on the Retention Policy.",
"description": "Cloudron makes a complete backup of your system based on this scheduled interval and keeps backups with the specified retention policy.",
"schedule": "Schedule",
"retentionPolicy": "Retention Policy",
"configure": "Configure"
@@ -633,7 +629,7 @@
},
"check": {
"noop": "Cloudron backups are disabled. Please ensure this server is backed up using alternate means. See https://docs.cloudron.io/backups/#storage-providers for more information.",
"sameDisk": "Backups are currently on the same disk as Cloudron itself. If the disk fills up with these backups, Cloudron will not function. A disk failure can also lead to complete data loss. See https://docs.cloudron.io/backups/#storage-providers for storing backups in an external location."
"sameDisk": "Cloudron backups are currently on the same disk as the Cloudron server instance. This is dangerous and can lead to complete data loss if the disk fails. See https://docs.cloudron.io/backups/#storage-providers for storing backups in an external location."
},
"backupEdit": {
"title": "Edit Backup",
@@ -657,9 +653,7 @@
},
"changeLogo": {
"title": "Choose Cloudron Avatar"
},
"backgroundImage": "Login page background image",
"clearBackgroundImage": "Clear"
}
},
"emails": {
"title": "Email",
@@ -787,8 +781,8 @@
"network": {
"title": "Network",
"ip": {
"title": "IPv4",
"description": "This IPv4 address is used to set up DNS A records.",
"title": "IP Address",
"description": "Cloudron uses this IP address when setting up DNS records.",
"provider": "Provider",
"interface": "Network Interface Name",
"configure": "Configure",
@@ -812,7 +806,7 @@
"showLogsAction": "Show Logs"
},
"configureIp": {
"title": "Configure IPv4 Provider",
"title": "Configure IP Provider",
"providerGenericDescription": "The Public IP address of the server will be automatically detected."
},
"ipv4": {
@@ -821,7 +815,7 @@
"ipv6": {
"address": "IPv6 Address",
"title": "IPv6",
"description": "This IPv6 address is used to set up DNS AAAA records."
"description": "Cloudron uses this IPv6 address to setup DNS AAAA records.\n"
},
"configureIpv6": {
"title": "Configure IPv6 Provider"
@@ -835,7 +829,7 @@
},
"services": {
"title": "Services",
"description": "Services implement functionality such as databases, email and authentication.",
"description": "Cloudron services implement functionality such as databases, email and authentication.",
"service": "Service",
"memoryUsage": "Memory Usage",
"memoryLimit": "Memory Limit",
@@ -869,20 +863,19 @@
"emailNotVerified": "Email not yet verified"
},
"timezone": {
"title": "System Time Zone",
"description": "The current timezone setting is <b>{{ timeZone }}</b>. This setting is used for scheduling backup and update tasks. Timestamps in the UI are always displayed using the browser's timezone."
"title": "Time Zone",
"description": "The current timezone setting is <b>{{ timeZone }}</b>.\nThis setting is used for scheduling backup and update tasks."
},
"updates": {
"title": "Updates",
"autoUpdateDisabled": "Automatic update for the platform and apps is <b>disabled</b>.",
"currentSchedule": "The current automatic update schedule for platform and apps is",
"version": "Platform version",
"showLogsAction": "Show Logs",
"changeScheduleAction": "Change Schedule",
"checkForUpdatesAction": "Check for Updates",
"updateAvailableAction": "Update Available",
"stopUpdateAction": "Stop Update",
"disabled": "Disabled",
"schedule": "Schedule",
"description": "Platform and App Updates are automatically applied based on the Schedule in the <a href=\"/#/settings\">System Time Zone</a>."
"stopUpdateAction": "Stop Update"
},
"privateDockerRegistry": {
"title": "Private Docker Registry",
@@ -1017,7 +1010,7 @@
"tooltipRemove": "Remove Domain",
"renewCerts": {
"title": "Renew certificates",
"description": "Let's Encrypt certificates are renewed automatically. Use this option to trigger a renewal immediately.",
"description": "Cloudron renews Let's Encrypt certificates automatically. Use this option to trigger a renewal immediately.",
"renewAllAction": "Renew All Certs",
"showLogsAction": "Show Logs"
},
@@ -1085,8 +1078,7 @@
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret",
"deSecToken": "deSEC Token"
"ovhAppSecret": "Application Secret"
},
"removeDialog": {
"title": "Really remove {{ domain }}?",
@@ -1144,8 +1136,7 @@
"copy": "Copy",
"clear": "Clear",
"pasteInfo": "For Paste use Ctrl+v"
},
"uploadTo": "Upload to {{ path }}"
}
},
"filemanager": {
"title": "File Manager",
@@ -1302,7 +1293,7 @@
"outbound": {
"tabTitle": "Outbound",
"title": "Email Relay",
"description": "This mail server (Smart host) will be used to send the outbound mails of apps installed under this domain.",
"description": "Cloudron will use this mail server (Smart host) to send the outbound mails of apps installed under this domain.",
"noopAdminDomainWarning": "Cloudron cannot send user invites, password reset and other notifications when email is disabled on the primary domain",
"noopNonAdminDomainWarning": "Cloudron cannot provide email sending for apps hosted under this domain when email is disabled.",
"mailRelay": {
@@ -1367,7 +1358,7 @@
"title": "Enable Email for {{ domain }}?",
"description": "This will configure Cloudron to receive emails for <b>{{ domain }}</b>. See the documentation for opening up the <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">required ports</a> for Cloudron Email.",
"noProviderInfo": "No DNS provider is set up. The DNS records listed in the Status tab have to be set up manually.",
"cloudflareInfo": "The mail server's domain <code>{{ adminDomain }}</code> is managed by Cloudflare. Please verify that Cloudflare proxying is disabled for <code>{{ mailFqdn }}</code> and set to <code>DNS only</code>. This is required because Cloudflare does not proxy email.",
"cloudflareInfo": "The domain <code>{{ adminDomain }}</code> is managed by Cloudflare. Please verify that Cloudflare proxying is disabled for <code>{{ mailFqdn }}</code> and set to <code>DNS only</code>. This is required because Cloudflare does not proxy email.",
"setupDnsCheckbox": "Set up Mail DNS records now",
"setupDnsInfo": "Use this option to automatically set up Email related DNS records. Leaving this option unchecked is useful for creating mail boxes and <a href=\"{{ importEmailDocsLink }}\">importing email</a> before going live.",
"enableAction": "Enable"
@@ -1503,14 +1494,14 @@
"resources": {
"memory": {
"title": "Memory Limit",
"description": "Maximum memory app can use",
"description": "Cloudron allocates 50% of this value as RAM and 50% as swap.",
"error": "Unable to set memory limit, try less.",
"resizeAction": "Resize"
},
"cpu": {
"setAction": "Scale",
"title": "CPU Limit",
"description": "Maximum percent of CPU app can use"
"setAction": "Set",
"title": "CPU Shares",
"description": "Percent of CPU time when system is under heavy load."
}
},
"storage": {
@@ -1594,18 +1585,17 @@
"packageVersion": "Package Version",
"lastUpdated": "Last Updated",
"checkForUpdatesAction": "Check for Updates",
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
"customAppUpdateInfo": "Updates are not available for custom apps",
"updateAvailableAction": "Update Available",
"repository": "Package Repository",
"installedAt": "Installed At"
"repository": "Package Repository"
},
"auto": {
"title": "Automatic Updates",
"description": "Cloudron periodically checks the <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> for updates.",
"description": "Cloudron periodically checks the App Store for updates. If you disable automatic updates, be sure to manually apply the updates.",
"enabled": "Automatic Updates is currently enabled.",
"disabled": "Automatic Updates is currently disabled.",
"disableAction": "Disable Auto-update",
"enableAction": "Enable Auto-update"
"disableAction": "Disable Automatic Updates",
"enableAction": "Enable Automatic Updates"
},
"noUpdates": "No new updates available"
},
@@ -1628,7 +1618,7 @@
},
"auto": {
"title": "Automatic Backups",
"description": "Backups are periodically created based on the <a href=\"{{ backupLink }}\">Backup Schedule</a>.",
"description": "Cloudron periodically creates a backup based on the <a href=\"{{ backupLink }}\">backup</a> settings.",
"enabled": "Automatic Backups is currently enabled.",
"disabled": "Automatic Backups is currently disabled.",
"disableAction": "Disable Automatic Backups",
@@ -1659,7 +1649,7 @@
},
"uninstall": {
"title": "Uninstall",
"description": "This will uninstall the app immediately and remove the app's data. The site will be inaccessible.",
"description": "This will uninstall the app immediately and remove all its data. The site will be inaccessible.",
"backupWarning": "App backups are not removed and will be cleaned up based on the backup policy. You can resurrect this app from an existing app backup using the following <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">instructions</a>.",
"uninstallAction": "Uninstall"
}
@@ -1673,8 +1663,7 @@
"openAction": "Open {{ app }}",
"firstTimeTitle": "First Time Usage",
"firstTimeCollapseHeader": "First time setup instructions",
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app.",
"checklist": "Admin Checklist"
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app."
},
"uninstallDialog": {
"title": "Uninstall {{ app }}",
@@ -1777,12 +1766,6 @@
"title": "Redis Configuration",
"enable": "Configure the app to use Redis",
"disable": "Disable Redis"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Admin Notes"
}
}
},
"login": {
@@ -1794,8 +1777,7 @@
"signInAction": "Sign in",
"resetPasswordAction": "Reset password",
"errorIncorrect2FAToken": "2FA token is invalid",
"errorInternal": "Internal error, try again later",
"loginWith": "Login with Cloudron"
"errorInternal": "Internal error, try again later"
},
"passwordReset": {
"title": "Password reset",
@@ -1876,8 +1858,7 @@
"es": "Spanish",
"ru": "Russian",
"pt": "Portuguese",
"da": "Danish",
"id": "Indonesian"
"da": "Danish"
},
"volumes": {
"title": "Volumes",
+2
View File
@@ -871,6 +871,8 @@
"changeScheduleAction": "Cambiar Programación",
"showLogsAction": "Mostrar Registros",
"version": "Versión de la Plataforma",
"currentSchedule": "El programa actual de actualización automática para la plataforma y las aplicaciones es",
"autoUpdateDisabled": "La actualización automática de la plataforma y las aplicaciones está <b> desactivada </b>.",
"title": "Actualizaciones"
},
"language": {
+36 -168
View File
@@ -156,8 +156,7 @@
"description": "Cloudron va importer les utilisateurs et les groupes depuis un annuaire LDAP externe ou Active Directory. La vérification du mot de passe pour l'authentification de ces utilisateurs se fait via le serveur externe. La synchronisation ne s'exécute pas automatiquement, elle doit être lancée manuellement.",
"subscriptionRequiredAction": "Paramétrer mon abonnement maintenant",
"providerOther": "Autre",
"providerDisabled": "Désactivé",
"disableWarning": "La source d'authentification de tous les utilisateurs existants sera réinitialisée pour utiliser la base de données locale."
"providerDisabled": "Désactivé"
},
"role": {
"usermanager": "Gestionnaire",
@@ -189,9 +188,7 @@
"errorInvalidEmail": "Cette adresse email est invalide",
"usernamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la première connexion",
"fallbackEmailPlaceholder": "Optionnel. Si laissé vide, ce sera l'adresse email principale qui sera utilisée",
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte",
"external2FA": "La configuration multi-facteur est gérée par une source externe",
"ldapGroups": "Groupes LDAP"
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte"
},
"group": {
"errorNameRequired": "Un nom est nécessaire",
@@ -274,7 +271,7 @@
},
"exposedLdap": {
"secret": {
"label": "Mot de passe Bind",
"label": "Mot de passe de liaison",
"description": "Toutes les requêtes LDAP doivent être authentifiées avec ce secret et le DN utilisateur <i>{{ userDN }}</i>",
"url": "URL du serveur"
},
@@ -285,8 +282,7 @@
"placeholder": "Adresse IP séparée par ligne ou sous-réseau",
"label": "Accès restreint"
},
"title": "Serveur d'annuaire",
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP"
"title": "Serveur d'annuaire"
},
"userImportDialog": {
"title": "Importer des utilisateurs",
@@ -349,10 +345,7 @@
"changeEmail": {
"errorEmailInvalid": "Cette adresse email est invalide",
"title": "Modifier l'adresse email principale",
"errorEmailRequired": "Une adresse email valide est nécessaire",
"email": "Nouvelle adresse e-mail",
"password": "Mot de passe pour confirmation",
"errorWrongPassword": "Mauvais mot de passe"
"errorEmailRequired": "Une adresse email valide est nécessaire"
},
"createAppPassword": {
"copyNow": "Veillez à copier le mot de passe maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
@@ -423,8 +416,7 @@
},
"changeBackgroundImage": {
"title": "Définir l'image d'arrière-plan"
},
"enable2FANotAvailable": "Non disponible pour les utilisateurs provenant d'une source d'authentification externe"
}
},
"backups": {
"title": "Sauvegardes",
@@ -508,7 +500,7 @@
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
"chown": "Le système de fichiers distant prend en charge chown",
"encryptedFilenames": "Crypter les noms de fichiers",
"encryptFilenames": "Chiffré les nom de fichiers"
"encryptFilenames": "Fichiers Cryptés"
},
"backupDetails": {
"title": "Informations sur la sauvegarde",
@@ -551,7 +543,7 @@
"description": "Sauvegarde persistante quelle que soit la politique de rétention",
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
},
"remotePath": "Chemin d'accès à distance"
"remotePath": "Répertoire Distant"
}
},
"emails": {
@@ -561,7 +553,7 @@
"location": "Emplacement",
"title": "Changer l'emplacement du serveur de messagerie",
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu",
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi."
"description": "Cloudron effectuera les modifications DNS nécessaires pour l'ensemble des domaines et redémarrera le serveur de messagerie. Les clients de messagerie sur ordinateur et sur mobile doivent être reconfigurés pour que ce nouvel emplacement soit utilisé comme serveur IMAP et SMTP."
},
"eventlog": {
"details": "Détails",
@@ -671,10 +663,6 @@
},
"action": {
"queue": "File d'attente"
},
"changeVirtualAllMailDialog": {
"title": "Dossier \"Tout les Emails\"",
"description": "Le dossier \"Tout les E-mails\" est un dossier contenant tout les e-mails de votre boite de réception. Ce dossier peut être utile pour les clients e-mails ne supportant pas les dossiers imbriqués."
}
},
"network": {
@@ -691,8 +679,7 @@
},
"dyndns": {
"title": "DNS dynamique",
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique.",
"showLogsAction": "Afficher les journaux"
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique."
},
"ip": {
"configure": "Paramétrer",
@@ -718,13 +705,7 @@
"address": "Adresse IPv6",
"title": "IPv6",
"description": "Cloudron utilise cette adresse IPv6 pour configurer les enregistrements DNS AAAA.\n"
},
"trustedIps": {
"description": "Les en-têtes HTTP provenant d'adresses IP correspondantes seront considérés comme sûrs",
"title": "Configurer les adresses IP de Confiance",
"summary": "{{ trustCount }} adresses IP de confiance"
},
"trustedIpRanges": "Adresses et plages d'IP de confiance. "
}
},
"settings": {
"title": "Paramètres",
@@ -791,6 +772,8 @@
"changeScheduleAction": "Modifier la fréquence",
"showLogsAction": "Afficher les journaux",
"version": "Version de la plateforme",
"currentSchedule": "La mise à jour automatique de la plateforme et des application a lieu",
"autoUpdateDisabled": "La mise à jour automatique de la plateforme et des applications est <b>désactivée</b>.",
"title": "Mises à jour"
},
"timezone": {
@@ -826,12 +809,7 @@
"subscriptionRequiredDescription": "Vous devriez trouver votre réponse dans notre <a href=\"{{ supportViewLink }}\" target=\"_blank\">documentation</a>, vous pouvez également poser votre question sur le <a href=\"{{ forumLink }}\" target=\"_blank\">forum</a>.",
"title": "Ticket",
"emailVerifyAction": "Confirmer maintenant",
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident.",
"typeBilling": "Problème de facturation"
},
"help": {
"description": "Veuillez utiliser les ressources suivantes pour obtenir de l'aide\n* [Forum Cloudron]({{ forumLink }}) - Veuillez utiliser les catégories d'assistance et d'applications spécifiques pour vos questions.\n* [Documentation et base de connaissances de Cloudron]({{ docsLink }})\n* [Packaging d'applications personnalisées et API]({{ packagingLink }})\n",
"title": "Aide"
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident."
}
},
"notifications": {
@@ -939,8 +917,7 @@
"packageVersion": "Version du package",
"appId": "ID de l'application",
"description": "Nom et version de l'application",
"title": "Informations sur l'application",
"repository": "Dépot de paquets"
"title": "Informations sur l'application"
},
"auto": {
"title": "Mises à jour automatiques",
@@ -949,8 +926,7 @@
"disabled": "Les mises à jour automatiques sont actuellement désactivées.",
"enabled": "Les mises à jour automatiques sont actuellement activées.",
"description": "Cloudron vérifie régulièrement les mises à jour disponibles dans l'App Store. Si vous désactivez les mises à jour automatiques, veillez à les faire manuellement."
},
"noUpdates": "Aucune nouvelle mise à jour disponible"
}
},
"backupsTabTitle": "Sauvegardes",
"storage": {
@@ -960,20 +936,13 @@
"noMounts": "Aucun volume n'est monté.",
"volume": "Volume",
"readOnly": "En lecture seule",
"title": "Montages",
"permissions": {
"label": "Permissions",
"readOnly": "Lecture seule",
"readWrite": "Lecture et écriture"
}
"title": "Montages"
},
"appdata": {
"moveAction": "Déplacer les données",
"dataDirPlaceholder": "Laisser vide pour utiliser la plateforme par défaut",
"description": "Si le serveur manque d'espace disque, utilisez-le pour déplacer les données de l'application vers un <a href=\"/#/volumes\">volume</a>. Toutes les données ici font partie de la sauvegarde de l'application.",
"title": "Données de l'application",
"diskUsage": "L'application utilise actuellement {{ size }} de stockage (en date du {{ date }}).",
"mountTypeWarning": "Le système de fichiers de destination doit prendre en charge les autorisations et la propriété des fichiers pour que le transfert fonctionne"
"title": "Données de l'application"
}
},
"security": {
@@ -986,8 +955,7 @@
"disableIndexingAction": "Désactiver l'indexation",
"txtPlaceholder": "Laisser vide pour autoriser les robots à indexer cette application",
"title": "Robots.txt"
},
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
}
},
"updateDialog": {
"updateAction": "Mettre à jour",
@@ -1081,8 +1049,7 @@
"uploadAction": "Charger le fichier de configuration de la sauvegarde",
"description": "Toutes les données créées depuis la dernière sauvegarde connue seront définitivement perdues. Il est fortement recommandé de sauvegarder les données actuelles avant de lancer un import.",
"title": "Importer la sauvegarde",
"importAction": "Importer",
"remotePath": "Chemin de la sauvegarde"
"importAction": "Importer"
},
"repairDialog": {
"fromBackup": "Restaurer depuis la sauvegarde :",
@@ -1153,8 +1120,7 @@
"time": "Créée le",
"packageVersion": "Version du package",
"description": "Les sauvegardes sont des instantanés complets de l'application. Vous pouvez utiliser les sauvegardes pour restaurer ou cloner l'application.",
"title": "Sauvegardes",
"downloadBackupTooltip": "Télécharger la sauvegarde"
"title": "Sauvegardes"
}
},
"graphs": {
@@ -1166,9 +1132,7 @@
"12h": "12 heures",
"6h": "6 heures"
},
"diskTitle": "Utilisation du disque",
"diskIOTotal": "total: lecture {{ read }} / écriture {{ write }}",
"networkIOTotal": "total: entrant {{ inbound }} / sortant {{ outbound }}"
"diskTitle": "Utilisation du disque"
},
"resources": {
"memory": {
@@ -1257,25 +1221,12 @@
"label": "Étiquette",
"clearIconAction": "Effacer Icône",
"clearIconDescription": "Cela récupérera le favicon de l'application."
},
"servicesTabTitle": "Services",
"turn": {
"enable": "Configurer l'application pour utiliser le serveur TURN intégré",
"disable": "Ne pas configurer les paramètres TURN de l'application. Les paramètres TURN de l'application sont laissés à leur valeurs par défaut. Vous pouvez les configurer à l'intérieur de l'application.",
"title": "Configuration de TURN"
},
"redis": {
"title": "Configuration de Redis",
"enable": "Configurer l'application pour utiliser Redis",
"disable": "Désactiver Redis"
}
},
"logs": {
"title": "Journaux",
"download": "Télécharger l'ensemble des journaux",
"clear": "Nettoyer",
"notFoundError": "Aucune tâche ou application de ce type",
"logsGoneError": "Fichier(s) journal(s) introuvable(s)"
"clear": "Nettoyer"
},
"volumes": {
"name": "Nom",
@@ -1312,11 +1263,7 @@
"title": "Mettre à jour le volume {{ volume }}"
},
"mountStatus": "Statut du montage",
"type": "Type",
"editVolumeDialog": {
"title": "Modifier le volume {{ name }}"
},
"editActionTooltip": "Modifier le volume"
"type": "Type"
},
"lang": {
"en": "Anglais",
@@ -1330,8 +1277,7 @@
"zh_Hans": "Chinois (Simplifié)",
"es": "Espagnol",
"ru": "Russe",
"pt": "Portugais",
"da": "Danois"
"pt": "Portugais"
},
"email": {
"mailboxboxDialog": {
@@ -1576,19 +1522,10 @@
"vultrToken": "Token Vultr",
"wellKnownDescription": "Les valeurs seront utilisées par Cloudron pour répondre aux URL <code>/.well-known/</code>. Notez qu'une application doit être disponible sur le domaine nu <code>{{ domaine }}</code> pour que cela fonctionne. Consultez la <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> pour plus d'informations.",
"hetznerToken": "Token Hetzner",
"jitsiHostname": "Emplacement de Jitsi",
"cloudflareDefaultProxyStatus": "Activer le proxy pour les nouveaux enregistrements DNS",
"porkbunApikey": "Clé API",
"porkbunSecretapikey": "Clé API secrète",
"dnsimpleAccessToken": "Jeton d'accès",
"ovhEndpoint": "Point de terminaison",
"bunnyAccessKey": "Bunny Access Key",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
"jitsiHostname": "Emplacement de Jitsi"
},
"changeDashboardDomain": {
"description": "Cette action entraînera le déplacement du tableau de bord vers le sous-domaine <code>my</code> du domaine sélectionné.",
"description": "Cette action entraînera le déplacement du tableau de bord et du serveur de messagerie vers le sous-domaine <code>my</code> du domaine sélectionné.",
"showLogsAction": "Afficher les journaux",
"cancelAction": "Annuler",
"changeAction": "Changer le domaine",
@@ -1614,8 +1551,7 @@
"domainWellKnown": {
"title": "Emplacements Well-Known de {{ domain }}"
},
"tooltipWellKnown": "Définir des emplacements Well-Known",
"count": "Nombre de domaines: {{ count }}"
"tooltipWellKnown": "Définir des emplacements Well-Known"
},
"branding": {
"footer": {
@@ -1696,8 +1632,7 @@
"download": "Télécharger",
"extract": "Extraire ici",
"chown": "Modifier la propriété",
"rename": "Renommer",
"open": "Ouvrir"
"rename": "Renommer"
},
"symlink": "Symlink vers {{ target }}",
"empty": "Aucun fichier",
@@ -1743,8 +1678,7 @@
"renameDialog": {
"rename": "Renommer",
"newName": "Nouveau nom",
"title": "Renommer {{ fileName }}",
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant?"
"title": "Renommer {{ fileName }}"
},
"newFileDialog": {
"create": "Créer",
@@ -1757,19 +1691,7 @@
"removeDialog": {
"reallyDelete": "Voulez-vous vraiment supprimer ces fichiers ?"
},
"title": "Gestionnaire de fichiers",
"uploader": {
"uploading": "Téléversement",
"exitWarning": "Téléversement toujours en cours. Voulez-vous vraiment fermer cette page?"
},
"deleteInProgress": "Suppression en cours",
"textEditor": {
"undo": "Annuler",
"redo": "Refaire",
"save": "Enregistrer"
},
"extractionInProgress": "Décompression en cours",
"pasteInProgress": "Collage en cours"
"title": "Gestionnaire de fichiers"
},
"terminal": {
"contextmenu": {
@@ -1810,8 +1732,7 @@
"selectPeriodLabel": "Période sélectionnée",
"cpuUsage": {
"graphTitle": "Pourcentage",
"title": "Utilisation du microprocesseur",
"graphSubtext": "Seules les applications utilisant plus de {{ threshold }} de processeur sont affichées"
"title": "Utilisation du microprocesseur"
},
"systemMemory": {
"graphSubtext": "Seules les applications utilisant plus de 1GB de mémoire sont affichées",
@@ -1825,22 +1746,9 @@
"title": "Utilisation du disque",
"usedInfo": "{{ used }} utilisé de {{ size }}",
"uninstalledApp": "Désinstaller App",
"diskSpeed": "Vitesse : {{ speed }} MB/sec",
"volumeContent": "Ce disque est le volume <code>{{ name }}</code>"
"diskSpeed": "Vitesse : {{ speed }} MB/sec"
},
"title": "Info système",
"info": {
"platformVersion": "Version de la Plate-forme",
"vendor": "Vendeur",
"product": "Produit",
"memory": "Mémoire",
"uptime": "Durée de fonctionnement",
"activationTime": "Heure de création de Cloudron",
"title": "Informations"
},
"graphs": {
"title": "Graphiques"
}
"title": "Info système"
},
"services": {
"refresh": "Rafraîchir",
@@ -1895,9 +1803,7 @@
"password": "Mot de passe",
"username": "Nom d'utilisateur",
"errorIncorrectCredentials": "Nom d'utilisateur ou mot de passe incorrect",
"loginTo": "Se connecter à",
"errorIncorrect2FAToken": "Le jeton 2FA n'est pas valide",
"errorInternal": "Erreur interne, réessayer ultérieurement"
"loginTo": "Se connecter à"
},
"newLoginEmail": {
"salutation": "Bonjour <%= user %>,",
@@ -1913,43 +1819,5 @@
"mounts": {
"description": "Les applications peuvent accéder aux <a href=\"/#/volumes\">volumes</a> montés via le répertoire <code>/media/{volume name}</code>. Ces données ne sont pas incluses dans la sauvegarde de l'application."
}
},
"oidc": {
"client": {
"signingAlgorithm": "Algorithme de signature",
"name": "Nom",
"id": "ID du client",
"secret": "Secret du client",
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)",
"logoutRedirectUri": "Url de retour après déconnexion (facultatif)"
},
"description": "Cloudron peut agir en tant que fournisseur OpenID Connect pour les applications internes et les services externes.",
"deleteClientDialog": {
"description": "Cela déconnectera toutes les applications OpenID externes de ce Cloudron utilisant cet identifiant client.",
"title": "Supprimer définitivement le client {{ client }}?"
},
"newClientDialog": {
"title": "Ajouter un client",
"description": "Ajouter de nouveaux paramètres pour le client OpenID connect.",
"createAction": "Créer"
},
"title": "OpenID Connect Provider",
"editClientDialog": {
"title": "Modifier le client {{ client }}"
},
"env": {
"discoveryUrl": "URL de découverte",
"logoutUrl": "URL de déconnexion",
"profileEndpoint": "Point de terminaison pour le profil",
"keysEndpoint": "Point de terminaison pour les clés",
"tokenEndpoint": "Point de terminaison pour les jetons",
"authEndpoint": "Point de terminaison pour l'authentification"
},
"clients": {
"title": "Clients",
"newClient": "Nouveau client",
"empty": "Aucun client pour le moment"
}
},
"automation": "Automatisation"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"apps": {
"tagsFilterHeaderAll": "Semua Tag",
"adminPageActionTooltip": "Halaman Admin",
"domainsFilterHeader": "Semua Domain",
"groupsFilterHeader": "Semua Grup",
"addAppAction": "Tambah Aplikasi",
"title": "Aplikasi Saya",
"tagsFilterHeader": "Tag: {{ tags }}"
},
"main": {
"dialog": {
"no": "Tidak",
"yes": "Ya",
"delete": "Hapus",
"save": "Simpan"
},
"table": {
"date": "Tanggal"
}
}
}
+2
View File
@@ -1151,6 +1151,8 @@
"changeScheduleAction": "Cambia Pianificazione",
"showLogsAction": "Visualizza Logs",
"version": "Versione piattaforma",
"currentSchedule": "L'attuale programma di aggiornamento automatico per piattaforma e app è",
"autoUpdateDisabled": "L'aggiornamento automatico per la piattaforma e le app è <b>disabilitato</b>.",
"title": "Aggiornamenti"
},
"timezone": {
+25 -42
View File
@@ -30,9 +30,6 @@
"addApplinkAction": "App link toevoegen",
"filter": {
"clearAll": "Alles verwijderen"
},
"apps": {
"count": "Totaal apps: {{ count }}"
}
},
"main": {
@@ -84,8 +81,7 @@
"justNow": "zojuist",
"yeserday": "Gisteren",
"minutesAgo": "{{ m }} minuten geleden",
"hoursAgo": "{{ h }} uur geleden",
"never": "Nooit"
"hoursAgo": "{{ h }} uur geleden"
},
"navbar": {
"users": "Gebruikers"
@@ -278,8 +274,7 @@
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"fallbackEmailPlaceholder": "Optioneel. Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding",
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron",
"ldapGroups": "LDAP Groepen"
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron"
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
@@ -633,7 +628,7 @@
},
"check": {
"noop": "Cloudron backups zijn uitgeschakeld. Zorg ervoor dat deze server op een andere manier wordt geback-upt. Kijk op https://docs.cloudron.io/backups/#storage-providers voor meer informatie.",
"sameDisk": "Backups staan momenteel op dezelfde schijf als Cloudron zelf. Als de disk volloopt met deze backups zal Cloudron niet meer werken. Een defecte disk kan ook leiden tot volledig gegevensverlies. Kijk op https://docs.cloudron.io/backups/#storage-providers hoe je backups op een externe locatie kan zetten."
"sameDisk": "Cloudron backups staan momenteel op dezelfde schijf als deze Cloudron server. Dit is gevaarlijk en kan leiden tot gegevensverlies als de schijf defect raakt. Kijk op https://docs.cloudron.io/backups/#storage-providers hoe je backups op een externe locatie kan zetten."
},
"backupEdit": {
"preserved": {
@@ -657,9 +652,7 @@
},
"changeLogo": {
"title": "Kies een Cloudron-afbeelding"
},
"backgroundImage": "Inlogpagina achtergrond afbeelding",
"clearBackgroundImage": "Leegmaken"
}
},
"emails": {
"title": "E-mail",
@@ -837,8 +830,7 @@
"ovhEndpoint": "Eindpunt",
"ovhConsumerKey": "Consumer sleutel",
"ovhAppKey": "Applicatie sleutel",
"ovhAppSecret": "Applicatie geheim",
"deSecToken": "deSEC Token"
"ovhAppSecret": "Applicatie geheim"
},
"title": "Domeinen & Certificaten",
"addDomain": "Domein toevoegen",
@@ -971,14 +963,14 @@
"resources": {
"memory": {
"title": "Geheugenlimiet",
"description": "Maximum geheugen dat een app kan gebruiken",
"description": "Cloudron wijst 50% van deze waarde toe als RAM en 50% als swap.",
"resizeAction": "Grootte wijzigen",
"error": "Kan geheugenlimiet niet instellen, probeer minder."
},
"cpu": {
"setAction": "Instellen",
"title": "CPU Limiet",
"description": "Maximum percentage CPU dat een app kan gebruiken"
"setAction": "Vastleggen",
"title": "CPU Shares",
"description": "Percentage CPU-tijd wanneer het systeem zwaar wordt belast."
}
},
"storage": {
@@ -1038,18 +1030,17 @@
"packageVersion": "Pakketversie",
"lastUpdated": "Laatst geüpdatet",
"checkForUpdatesAction": "Controleer op updates",
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
"customAppUpdateInfo": "Er zijn geen updates beschikbaar voor deze maatwerk app",
"updateAvailableAction": "Update beschikbaar",
"repository": "Pakket Opslagplaats",
"installedAt": "Geïnstalleerd op"
"repository": "Pakket Opslagplaats"
},
"auto": {
"title": "Automatische updates",
"enabled": "Automatische updates zijn momenteel ingeschakeld.",
"disabled": "Automatische updates zijn momenteel uitgeschakeld.",
"disableAction": "Auto-update uitschakelen",
"enableAction": "Auto-update inschakelen",
"description": "Cloudron controleert periodiek de <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> op updates."
"disableAction": "Automatische updates uitschakelen",
"enableAction": "Automatische updates inschakelen",
"description": "Cloudron controleert de App Store periodiek op updates. Als je dit uitschakelt zorg er dan voor dat je updates handmatig installeert."
},
"noUpdates": "Geen nieuwe updates beschikbaar"
},
@@ -1117,8 +1108,7 @@
"customAppUpdateWarning": "Dit is een aangepaste app en niet geïnstalleerd vanuit de App Store, het krijgt hierdoor geen updates. Lees de <a target=\"_blank\" href=\"{{ docsLink }}\">documentatie</a> over hoe je een aangepaste app kunt updaten.",
"appDocsUrl": "Bekijk de <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} documentatie</a> voor informatie en tips over deze app. Indien je meer hulp nodig hebt ga dan naar Cloudron's <a target=\"_blank\" href=\"{{ forumUrl }}\">{{ title }} forum</a>.",
"sso": "Deze app is ingesteld voor authenticatie via het Cloudron gebuikersadresboek. Cloudron gebruikers kunnen inloggen en het direct gebruiken.",
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox.",
"checklist": "Admin Controlelijst"
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox."
},
"uninstallDialog": {
"uninstallAction": "De-installeer",
@@ -1223,23 +1213,17 @@
"title": "Redis configuratie",
"enable": "Configureer de app om Redis te gebruiken",
"disable": "Redis uitschakelen"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Admin Notities"
}
}
},
"network": {
"title": "Netwerk",
"ip": {
"title": "IPv4",
"title": "IP Adres",
"provider": "Aanbieder",
"interface": "Naam netwerkinterface",
"configure": "Configureer",
"interfaceDescription": "Toon beschikbare apparaten op deze server met:",
"description": "Cloudron gebruikt dit IPv4 adres om de DNS records in te stellen.",
"description": "Cloudron gebruikt dit IP adres tijdens het instellen van DNS records.",
"detected": "gedetecteerd",
"address": "IP adres"
},
@@ -1259,7 +1243,7 @@
"showLogsAction": "Toon logbestanden"
},
"configureIp": {
"title": "Configureer IPv4 aanbieder",
"title": "Configureer IP aanbieder",
"providerGenericDescription": "Het publieke IP adres van deze server wordt automatisch gedetecteerd."
},
"ipv4": {
@@ -1316,10 +1300,12 @@
},
"timezone": {
"title": "Tijdzone",
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>. Deze instelling wordt gebruikt voor backup planning en update taken. Tijdseenheden in de gebruikersschermen zijn op basis van de browers tijdzone."
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>.\nDeze instelling wordt gebruikt voor backup planning en update taken."
},
"updates": {
"title": "Updates",
"autoUpdateDisabled": "Automatische update voor het platform en apps is <b>uitgeschakeld</b>.",
"currentSchedule": "De huidige automatische update planning voor het platform en de apps is",
"showLogsAction": "Toon logbestanden",
"changeScheduleAction": "Planning aanpassen",
"checkForUpdatesAction": "Controleer op updates",
@@ -1491,8 +1477,7 @@
"upload": {
"title": "Uploaden bestand naar {{ name }}"
},
"uploadToTmp": "Upload naar /tmp",
"uploadTo": "Upload naar {{ path }}"
"uploadToTmp": "Upload naar /tmp"
},
"filemanager": {
"title": "Bestandsbeheer",
@@ -1714,7 +1699,7 @@
"setupDnsInfo": "Gebruik deze optie om automatisch e-mail gerelateerde DNS records in te stellen. Het nu niet inschakelen kan handig zijn om eerst e-mail boxen aan te maken en <a href=\"{{ importEmailDocsLink }}\">e-mails te importeren</a> voor ingebruikname.",
"enableAction": "Inschakelen",
"description": "Hiermee wordt Cloudron zo geconfigureerd dat e-mails ontvangen worden voor <b>{{ domain }}</b>. In de documentatie staat beschreven welke <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">benodigde poorten</a> ingesteld dienen te worden voor Cloudron Email.",
"cloudflareInfo": "Het domein van de mailserver <code>{{ adminDomain }}</code> wordt beheerd door Cloudflare. Zorg ervoor dat Cloudflare proxying uitgeschakeld is voor <code>{{ mailFqdn }}</code> en ingesteld is op <code>DNS only</code>. Dit is noodzakelijk omdat Cloudflare geen e-mail-proxy kan uitvoeren."
"cloudflareInfo": "Het domein <code>{{ adminDomain }}</code> wordt beheerd door Cloudflare. Zorg ervoor dat Cloudflare proxying uitgeschakeld is voor <code>{{ mailFqdn }}</code> en ingesteld is op <code>DNS only</code>. Dit is noodzakelijk omdat Cloudflare geen e-mail-proxy kan uitvoeren."
},
"disableEmailDialog": {
"title": "E-mail Server voor {{ domain }} uitschakelen?",
@@ -1791,8 +1776,7 @@
"2faToken": "2FA Token (indien ingeschakeld)",
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
"errorIncorrect2FAToken": "2FA token is niet geldig",
"errorInternal": "Interne fout, probeer later opnieuw",
"loginWith": "Login met Cloudron"
"errorInternal": "Interne fout, probeer later opnieuw"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1870,8 +1854,7 @@
"es": "Spaans",
"ru": "Russisch",
"pt": "Portugees",
"da": "Deens",
"id": "Indonesisch"
"da": "Deens"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Wachtwoord herstellen",
+156
View File
@@ -0,0 +1,156 @@
{
"apps": {
"title": "As Minhas Aplicações",
"noApps": {
"description": "Que tal instalar algumas? Vê a <a href=\"{{ appStoreLink }}\">Loja de Aplicações</a>",
"title": "Sem aplicações instaladas!"
},
"groupsFilterHeader": "Selecionar Grupo",
"addApplinkAction": "Adicionar Applink",
"noAccess": {
"title": "Não tem acesso a nenhuma aplicação.",
"description": "Assim que tiver, elas vão aparecer aqui."
},
"configActionTooltip": "Configurar",
"logsActionTooltip": "Eventos",
"infoActionTooltip": "Informação",
"adminPageActionTooltip": "Página de Adminstração",
"searchPlaceholder": "Pesquisar Aplicações",
"stateFilterHeader": "Todos os Estados",
"tagsFilterHeader": "Etiquetas: {{ tags }}",
"tagsFilterHeaderAll": "Todas as Etiquetas",
"domainsFilterHeader": "Todos os Domínios",
"auth": {
"sso": "Entrar com as credenciais Cloudron",
"nosso": "Entrar com conta dedicada",
"email": "Entrar com endereço de email"
},
"addAppAction": "Adicionar Aplicação",
"addAppproxyAction": "Adicionar Appproxy",
"filter": {
"clearAll": "Limpar Tudo"
}
},
"main": {
"displayName": "Nome de Apresentação",
"rebootDialog": {
"warning": "Reiniciar o servidor irá causar que todas as aplicações instaladas neste Cloudron fiquem indisponíveis temporariamente!",
"description": "Utilize isto para aplicar atualizações de segurança ou se experienciar comportamento inesperado. Todas as aplicações e serviços em execução neste Cloudron vão iniciar automaticamente quando o reinício estiver completo.",
"title": "Realmente reiniciar o servidor?",
"rebootAction": "Reiniciar agora"
},
"offline": "O Cloudron está offline. A ligar novamente…",
"dialog": {
"cancel": "Cancelar",
"save": "Guardar",
"close": "Fechar",
"no": "Não",
"yes": "Sim"
},
"logout": "Terminar Sessão",
"username": "Nome de Utilizador",
"actions": "Ações",
"table": {
"date": "Data"
},
"pagination": {
"next": "seguinte",
"prev": "anterior",
"perPageSelector": "Mostrar {{ n }} por página"
},
"action": {
"reboot": "Reiniciar",
"logs": "Eventos"
},
"clipboard": {
"copied": "Copiado para a área de transferência",
"clickToCopy": "Clique para copiar",
"clickToCopyBackupId": "Clique para copiar o ID da cópia de segurança"
},
"searchPlaceholder": "Pesquisar",
"multiselect": {
"selected": "{{ n }} selecionados",
"select": "Selecionar",
"filterPlaceholder": "Escreva para filtrar opções"
},
"prettyDate": {
"justNow": "agora mesmo",
"yeserday": "Ontem",
"minutesAgo": "{{ m }} minutos atrás",
"hoursAgo": "{{ h }} horas atrás"
},
"navbar": {
"users": "Utilizadores"
},
"disableAction": "Desativar",
"enableAction": "Ativar",
"statusEnabled": "Ativado",
"statusDisabled": "Desativado"
},
"appstore": {
"category": {
"analytics": "Estatísticas",
"game": "Jogos",
"project": "Gestão de Projetos",
"all": "Tudo",
"popular": "Popular",
"newApps": "Novas Aplicações",
"chat": "Chat",
"blog": "Blog",
"document": "Documentos",
"crm": "CRM",
"forum": "Fórum",
"gallery": "Galeria",
"finance": "Finanças",
"git": "Alojamento de Código",
"email": "Email",
"hosting": "Alojamento Web",
"media": "Multimédia",
"learning": "Aprendizagem",
"notes": "Notas",
"sync": "Sincronização de Ficheiros",
"wiki": "Wiki",
"vpn": "VPN",
"federated": "Federados"
},
"installDialog": {
"lastUpdated": "Última atualização a {{ date }}",
"locationPlaceholder": "Deixe em branco para usar o domínio de raiz",
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores. Esta definição determina se a aplicação está ou não visível no painel do utilizador.",
"memoryRequirement": "Requere pelo menos {{ size }} de memória",
"location": "Localização",
"manualWarning": "Adicione um registo A manualmente para <b>{{ location }}</b> apontando para o endereço IP público deste Cloudron",
"userManagement": "Gestão de utilizadores",
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
"installAnywayAction": "Instalar na mesma",
"doInstallAction": "Instalar {{ dnsOverwrite ? 'e sobrescrever DNS' : '' }}",
"userManagementSelectUsers": "Apenas permitir os seguintes utilizadores e grupos",
"errorUserManagementSelectAtLeastOne": "Selecione pelo menos um utilizador ou grupo",
"users": "Utilizadores",
"groups": "Grupos",
"configuredForCloudronEmail": "Esta aplicação está pré-configurada para ser utilizada com o <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email do Cloudron</a>.",
"lowOnResources": "Este Cloudron está baixo em recursos.",
"pleaseUpgradeServer": "Por favor, atualize para um servidor com mais memória. Em alternativa, liberte recursos removendo aplicações que não utiliza.",
"subscriptionRequired": "Para instalar mais aplicação, uma subscrição paga é necessária.",
"setupSubscriptionAction": "Configurar Subscrição",
"installAction": "Instalar",
"cloudflarePortWarning": "O proxy do Cloudflare deve estar desativado para o domínio da aplicação para que possa aceder a esta porta",
"titleAndVersion": "Esta aplicação inclui {{ title }} {{ version }}"
},
"title": "Loja de Aplicações",
"searchPlaceholder": "Pesquise por alternativas como Github, Dropbox, Slack, Trello, …",
"noAppsFound": "Nenhuma aplicação encontrada.",
"appMissing": "Falta uma aplicação? Contacte-nos.",
"unstable": "Instável",
"appNotFoundDialog": {
"description": "Não existe nenhuma aplicação <b>{{ appId }}</b> com a versão <b>{{ version }}</b>.",
"title": "Aplicação não encontrada"
},
"accountDialog": {
"titleSignUp": "Registar com Cloudron.io",
"titleLogin": "Entrar com Cloudron.io"
}
}
}
+29 -69
View File
@@ -30,9 +30,6 @@
"addApplinkAction": "Добавить App Link",
"filter": {
"clearAll": "Очистить все"
},
"apps": {
"count": "Всего приложений: {{ count }}"
}
},
"main": {
@@ -52,8 +49,7 @@
"justNow": "только что",
"yeserday": "Вчера",
"minutesAgo": "{{ m }} минут назад",
"hoursAgo": "{{ h }} часов назад",
"never": "Никогда"
"hoursAgo": "{{ h }} часов назад"
},
"logout": "Выйти",
"dialog": {
@@ -166,10 +162,7 @@
"loginAction": "Логин",
"switchToSignUpAction": "Ещё нет учётной записи? Зарегистрироваться",
"createAccountAction": "Создать учётную запись",
"switchToLoginAction": "Уже есть учётная запись? Войти",
"setupWithTokenAction": "Настройка",
"setupToken": "Настроить Токен",
"titleToken": "Войти с Настроенным Токеном"
"switchToLoginAction": "Уже есть учётная запись? Войти"
},
"title": "Магазин приложений",
"noAppsFound": "Приложения не найдены.",
@@ -224,7 +217,7 @@
"require2FAWarning": "Сперва настройте 2FA, чтобы иметь доступ к аккаунту в будущем."
},
"externalLdap": {
"description": "Эта настройка будет сихронизировать и идентифицировать пользователй и группы из внешнего сервера LDAP или AcriveDirectory. Синхронизация запускается с периодичностью, но также может быть запущена вручную.",
"description": "Cloudron будет синхронизировать пользователей и группы с внешнего сервера LDAP или ActiveDirectory. Проверка пароля для аутентификации таких пользователей выполняется на внешнем сервере. Синхронизация не запускается автоматически, ее нужно активировать вручную.",
"bindPassword": "Привязать пароль (необязательно)",
"bindUsername": "Привязать Уникальное имя (DN)/Имя пользователя (необязательно)",
"title": "Подключиться к удалённому каталогу",
@@ -241,14 +234,13 @@
"groupFilter": "Фильтр группы",
"groupnameField": "Поле с именем группы",
"auth": "Авторизоваться",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа в Cloudron",
"showLogsAction": "Показать логи",
"syncAction": "Синхронизировать",
"configureAction": "Настроить",
"errorSelfSignedCert": "Сервер использует недействительный или самоподписанный сертификат.",
"providerOther": "Другое",
"providerDisabled": "Отключить",
"disableWarning": "Источник аутентификации будет сброшен до локальных паролей для всех активных пользователей."
"providerDisabled": "Отключить"
},
"subscriptionDialog": {
"title": "Требуется подписка",
@@ -277,9 +269,7 @@
"errorDisplayNameRequired": "Требуется имя",
"activeCheckbox": "Пользователь активен",
"fallbackEmailPlaceholder": "Необязательно. Если не указано, будет использоваться основной почтовый ящик",
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации",
"external2FA": "Настройка 2FA осуществляется внешним ресурсом аутентификации",
"ldapGroups": "Группы LDAP"
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации"
},
"deleteUserDialog": {
"title": "Удалить пользователя {{ username }}",
@@ -366,7 +356,7 @@
"exposedLdap": {
"title": "Сервер LDAP",
"ipRestriction": {
"description": "Ограничьте доступ к серверу каталогов только для определённого круга IP-адресов и диапазонов. Строки, начинающиеся с <code>#</code>, будут считаться комментарием.",
"description": "Сервер каталогов может быть ограничен для определённого круга IP адресов.",
"placeholder": "IP-адреса или подсети, разделённые строками",
"label": "Ограничить доступ"
},
@@ -376,8 +366,7 @@
"label": "Привязать пароль",
"description": "Все запросы LDAP должны быть идентифицированы при помощи данного секрета и уникального имени пользователя (DN) <i>{{ userDN }}</i>",
"url": "URL сервера"
},
"cloudflarePortWarning": "Для доступа к LDAP серверу через домен панели управления проксирование Cloudflare должно быть выключено"
}
},
"userImportDialog": {
"title": "Импорт пользователей",
@@ -467,10 +456,7 @@
"changeEmail": {
"title": "Изменить главный адрес электронной почты",
"errorEmailInvalid": "Неверный адрес электронной почты",
"errorEmailRequired": "Требуется действительный адрес электронной почты",
"email": "Новый адрес электронной почты",
"password": "Пароль для подтверждения",
"errorWrongPassword": "Неверный пароль"
"errorEmailRequired": "Требуется действительный адрес электронной почты"
},
"changeFallbackEmail": {
"title": "Изменить пароль электронной почты восстановления",
@@ -539,8 +525,7 @@
"packageVersion": "Версия контейнера",
"lastUpdated": "Обновлен",
"checkForUpdatesAction": "Проверить обновления",
"repository": "Репозиторий",
"installedAt": "Установлено"
"repository": "Репозиторий"
},
"auto": {
"description": "Cloudron периодически проверяет Магазин приложений на наличие обновлений. Если Вы выключаете автоматические обновления, не забывайте применять их вручную.",
@@ -639,12 +624,12 @@
"title": "Лимит памяти",
"error": "Не получилось установить лимит памяти, попробуйте меньшее значение.",
"resizeAction": "Изменить",
"description": "Максимальное количество ОЗУ, которое может использовать приложение."
"description": "Cloudron выделяет 50% этого значения из оперативной памяти и 50% из swap."
},
"cpu": {
"setAction": "Масштабировать",
"title": "Лимит CPU",
"description": "Максимальный процент CPU, который может быть задействован в работе приложения"
"setAction": "Установить",
"title": "Доля CPU",
"description": "Процент времени CPU, когда система находится под нагрузкой."
}
},
"storage": {
@@ -858,12 +843,6 @@
"title": "Настроить Redis",
"enable": "Настроить использование Redis в приложении",
"disable": "Отключить Redis"
},
"infoTabTitle": "Информация",
"info": {
"notes": {
"title": "Заметки администратора"
}
}
},
"backups": {
@@ -982,7 +961,7 @@
},
"check": {
"noop": "Резервное копирование Cloudron выключено. Пожалуйста, убедитесь, что на сервере настроены альтернативные способы резервного копирования. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers .",
"sameDisk": "В настоящий момент резервные копии сохраняются на системный диск с установленным Cloudron. Обратите внимание, что при полном заполнении диска бэкапами, Cloudron прекратит свою работу. Также, в случае поломки диска, вы можете полностью потерять доступ к вашим данным и бэкапам. Советуем ознакомиться с документацией https://docs.cloudron.io/backups/#storage-providers для выбора внешнего хранилища бэкапов."
"sameDisk": "Cloudron сохраняет резервные копии на том же диске, где находится он сам. Это опасно, и может привести к потере данных в случае ошибки диска. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers для выбора облачного поставщика."
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -1006,9 +985,7 @@
},
"changeLogo": {
"title": "Выбрать изображение Cloudron"
},
"backgroundImage": "Фоновое изображение экрана входа",
"clearBackgroundImage": "Очистить"
}
},
"emails": {
"title": "Электронная почта",
@@ -1135,8 +1112,8 @@
},
"network": {
"ip": {
"title": "IPv4",
"description": "Cloudron будет использовать данный IPv4 адрес для настройки A записей DNS.",
"title": "IP Адрес",
"description": "Cloudron будет использовать данный IP адрес для настройки записей DNS.",
"provider": "Источник",
"interface": "Имя сетевого интерфейса",
"configure": "Настроить",
@@ -1161,7 +1138,7 @@
"showLogsAction": "Показать логи"
},
"configureIp": {
"title": "Настроить поставщика IPv4",
"title": "Настроить источник IP",
"providerGenericDescription": "Публичный IP адрес сервера будет обнаружен автоматически."
},
"ipv4": {
@@ -1213,7 +1190,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Отменена и завершена",
"subscriptionSetupAction": "Обновить до Premium",
"subscriptionChangeAction": "Управление подпиской",
"subscriptionChangeAction": "Изменить подписку",
"subscriptionReactivateAction": "Реактивировать подписку",
"emailNotVerified": "Электронная почта не подтверждена"
},
@@ -1228,7 +1205,9 @@
"checkForUpdatesAction": "Проверить обновления",
"updateAvailableAction": "Обновление доступно",
"version": "Версия платформы",
"stopUpdateAction": "Остановить обновление"
"stopUpdateAction": "Остановить обновление",
"autoUpdateDisabled": "Автоматические обновления для платформы и приложений <b>выключены</b>.",
"currentSchedule": "Текущее расписание автоматических обновлений для платформы и приложений"
},
"privateDockerRegistry": {
"title": "Частный реестр Docker",
@@ -1304,10 +1283,6 @@
"enableAction": "Включить SSH доступ",
"title": "Удалённая поддержка",
"description": "Выберите эту опцию, чтобы позволить сотрудникам поддержки подключиться к Вашему серверу через SSH."
},
"help": {
"title": "Помощь",
"description": "Для поддержки и помощи, пожалуйста, воспользуйтесь следующими ресурсами:\n* [Форум Cloudron]({{ forumLink }}) - пожалуйста, задавайте вопросы в соответствующих темах Поддержки или конкретных приложений.\n* [Документация Cloudron & База знаний]({{ docsLink }})\n* [Создание сторонних приложений и API]({{ packagingLink }})\n"
}
},
"system": {
@@ -1332,19 +1307,7 @@
"graphTitle": "Процент",
"graphSubtext": "Отображаются приложения, использующие более {{ threshold }} CPU"
},
"selectPeriodLabel": "Выберите период",
"info": {
"platformVersion": "Версия Платформы",
"product": "Продукт",
"vendor": "Поставщик",
"memory": "Память",
"uptime": "Аптайм",
"activationTime": "Время создания Cloudron",
"title": "Информация"
},
"graphs": {
"title": "Графики"
}
"selectPeriodLabel": "Выберите период"
},
"eventlog": {
"title": "Журнал",
@@ -1430,8 +1393,7 @@
"ovhEndpoint": "Конечная точка",
"ovhConsumerKey": "Ключ пользователя",
"ovhAppKey": "Ключ приложения",
"ovhAppSecret": "Секрет приложения",
"deSecToken": "deSEC Токен"
"ovhAppSecret": "Секрет приложения"
},
"addDomain": "Добавить домен",
"removeDialog": {
@@ -1505,8 +1467,7 @@
"renameDialog": {
"newName": "Новое имя",
"rename": "Переименовать",
"title": "Переименовать {{ fileName }}",
"reallyOverwrite": "Файл с таким именем уже существует. Хотите перезаписать его?"
"title": "Переименовать {{ fileName }}"
},
"chownDialog": {
"newOwner": "Новый владелец",
@@ -1629,8 +1590,8 @@
"title": "Включить электронную почту для {{ domain }}?",
"setupDnsCheckbox": "Установить почтовые DNS записи",
"enableAction": "Включить",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Рекомендуем ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Почтовый домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен режим <code>только DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Прости ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен только режим <code>DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"setupDnsInfo": "Используйте данную опцию, чтобы автоматически настроить относящиеся к электронной почте записи DNS. Вы можете не отмечать её сразу, чтобы предварительно создать почтовые ящики и <a href=\"{{ importEmailDocsLink }}\">импортировать письма</a>."
},
"backAction": "Вернуться к электронной почте",
@@ -1789,8 +1750,7 @@
"2faToken": "2FA Токен (если включен)",
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginWith": "Войти с Cloudron"
"errorInternal": "Внутренняя ошибка, попробуйте позже"
},
"passwordReset": {
"title": "Сброс пароля",
+55 -117
View File
@@ -22,18 +22,14 @@
"auth": {
"email": "Đăng nhập bằng email",
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
"nosso": "Đăng nhập bằng tài khoản riêng",
"openid": "Đăng nhập bằng Cloudron OpenID"
"nosso": "Đăng nhập vào tài khoản riêng"
},
"addAppAction": "Thêm App",
"addApplinkAction": "Thêm link App",
"addApplinkAction": "Thêm đường link App",
"filter": {
"clearAll": "Xoá tất cả"
},
"addAppproxyAction": "Thêm proxy cho app",
"apps": {
"count": "Tổng số app: {{ count }}"
}
"addAppproxyAction": "Thêm proxy cho app"
},
"main": {
"logout": "Thoát",
@@ -84,8 +80,7 @@
"justNow": "mới đây",
"yeserday": "Hôm qua",
"minutesAgo": "{{ m }} phút trước",
"hoursAgo": "{{ h }} tiếng trước",
"never": "Chưa lần nào"
"hoursAgo": "{{ h }} tiếng trước"
},
"statusEnabled": "Đã bật",
"statusDisabled": "Đã tắt",
@@ -112,7 +107,7 @@
"finance": "Tài chính",
"git": "Chạy code",
"email": "Email",
"game": "Trò chơi",
"game": "Game",
"hosting": "Chạy web",
"media": "Hình ảnh",
"learning": "Học tập",
@@ -135,7 +130,7 @@
"manualWarning": "Thêm A record cho <b>{{ nơi cài đặt }}</b> vào địa chỉ IP công cộng của Cloudron này",
"userManagement": "Quản lý người dùng",
"userManagementMailbox": "Tất cả người dùng với hộp thư trên Cloudron này có quyền truy cập app.",
"userManagementLeaveToApp": "Để app quản lý người dùng",
"userManagementLeaveToApp": "Để phần quản lý người dùng cho app",
"userManagementAllUsers": "Cho phép tất cả người dùng trên Cloudron truy cập",
"errorUserManagementSelectAtLeastOne": "Chọn ít nhất một người dùng hay nhóm",
"users": "Người dùng",
@@ -143,10 +138,10 @@
"lowOnResources": "Cloudron này đang chạy gần hết bộ nhớ.",
"pleaseUpgradeServer": "Hãy nâng cấp server có bộ nhớ nhiều hơn. Hoặc, xoá những app không dùng đến để có thêm chỗ trống.",
"setupSubscriptionAction": "Cài đặt gói đăng ký",
"installAnywayAction": "Vẫn tải về",
"installAnywayAction": "Vẫn tải về luôn",
"installAction": "Tải về",
"subscriptionRequired": "Để cài đặt thêm app, hãy đăng ký gói trả phí.",
"userManagementNone": "App này có phần quản lý người dùng riêng. Cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
"userManagementNone": "App này có phần quản lý người dùng riêng. Phần cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
"userManagementSelectUsers": "Chỉ cho phép người dùng và nhóm sau",
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
@@ -172,10 +167,7 @@
"switchToLoginAction": "Đã có tài khoản rồi? Đăng nhập",
"switchToSignUpAction": "Chưa có tài khoản? Hãy đăng ký nhé",
"description": "Tài khoản này được dùng để truy cập Cửa hàng App và quản lý gói đăng ký của bạn",
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron",
"setupWithTokenAction": "Cài đặt",
"titleToken": "Đăng ký với Mã cài đặt",
"setupToken": "Cài đặt Mã"
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron"
},
"searchPlaceholder": "Tìm kiếm app thay thế cho Github, Dropbox, Slack, Trello, …",
"appMissing": "Thiếu app nào đó? Hãy nhắn cho chúng tôi.",
@@ -213,9 +205,7 @@
"username": "Tên đăng nhập",
"fullName": "Họ tên",
"fallbackEmailPlaceholder": "Không bắt buộc. Nếu không được xác định, email chính sẽ được sử dụng",
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký",
"external2FA": "Nguồn xác thực ngoài đang quản lý cài đặt Mã xác minh 2 Bước",
"ldapGroups": "Nhóm LDAP"
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký"
},
"addUserDialog": {
"addUserAction": "Thêm người dùng",
@@ -233,7 +223,7 @@
"configureAction": "Cấu hình",
"syncAction": "Đồng bộ",
"showLogsAction": "Hiển thị log",
"autocreateUsersOnLogin": "Tự động tạo người dùng khi họ đăng ",
"autocreateUsersOnLogin": "Tự động tạo tài khoản người dùng khi họ đăng nhập vào Cloudron",
"auth": "Xác minh",
"groupnameField": "Vùng tên nhóm",
"groupFilter": "Lọc nhóm",
@@ -247,11 +237,10 @@
"provider": "Nhà cung cấp",
"noopInfo": "Xác thực LDAP chưa được thiết lập.",
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
"description": "Cài đặt này đồng bộ và xác thực người dùng và nhóm từ một server LDAP hay ActiveDirectory bên ngoài. Sự đồng bộ hóa này được chạy theo chu kỳ nhưng cũng có thể được khởi động bằng tay.",
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
"title": "Kết nối thư mục ngoài",
"providerOther": "Khác",
"providerDisabled": "Đã tắt",
"disableWarning": "Nguồn mã xác minh cho tất cả người dùng hiện hữu sẽ được cài đặt lại dựa trên cơ sở dữ liệu mật khẩu nội bộ trên server."
"providerDisabled": "Đã tắt"
},
"users": {
"inactiveTooltip": "Người dùng không hoạt động",
@@ -261,12 +250,12 @@
"notActivatedYetTooltip": "Người dùng chưa được kích hoạt",
"externalLdapTooltip": "Từ thư mục LDAP ngoài",
"usermanagerTooltip": "Người dùng này có thể quản lý nhóm và những người dùng khác",
"adminTooltip": "Người dùng này admin",
"superadminTooltip": "Người dùng này superadmin",
"adminTooltip": "Người dùng này có vai trò admin",
"superadminTooltip": "Người dùng này có vai trò superadmin",
"empty": "Không tìm thấy người dùng",
"groups": "Nhóm",
"user": "Người dùng",
"transferOwnershipTooltip": "Chuyển nhượng quyền sở hữu",
"transferOwnershipTooltip": "Chuyển đổi quyền sở hữu",
"invitationTooltip": "Mời Người dùng",
"setGhostTooltip": "Nhập vai",
"count": "Tổng ng dùng: {{ count }}",
@@ -345,7 +334,7 @@
"enabled": "Đã bật",
"title": "Máy chủ chỉ mục",
"ipRestriction": {
"description": "Giới hạn quyền truy cập máy chủ chỉ mục cho những địa chỉ IP hoặc khoảng vùng cụ thể. Những dòng bắt đầu bằng dấu <code>#</code> được xem như ghi chú thêm.",
"description": "Máy chủ chỉ mục có thể được giới hạn cho những địa chỉ IP hoặc khoảng vùng cụ thể.",
"placeholder": "Viết xuống dòng những địa chỉ IP hoặc Subnet",
"label": "Giới hạn quyền truy cập"
},
@@ -353,8 +342,7 @@
"label": "Mật khẩu bind",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
"url": "URL máy chủ"
},
"cloudflarePortWarning": "Cần tắt proxy Cloudflare cho tên miền dashboard để truy cập LDAP server"
}
},
"userImportDialog": {
"success": "{{ count }} người dùng đã được nhập vào.",
@@ -490,10 +478,7 @@
"changeEmail": {
"title": "Thay đổi email chính",
"errorEmailInvalid": "Email không hợp lệ",
"errorEmailRequired": "Bạn cần nhập một email hợp lệ",
"email": "Thêm địa chỉ mail mới",
"password": "Mật khẩu để xác nhận",
"errorWrongPassword": "Sai mật khẩu"
"errorEmailRequired": "Bạn cần nhập một email hợp lệ"
},
"disable2FAAction": "Tắt xác minh hai bước",
"changeFallbackEmail": {
@@ -514,8 +499,7 @@
"passwordResetNotification": {
"title": "Đã đặt lại mật khẩu thành công",
"body": "Email đã được gửi đến {{ email }}"
},
"enable2FANotAvailable": "Không cài được cho người dùng từ nguồn xác minh ngoài"
}
},
"backups": {
"location": {
@@ -633,7 +617,7 @@
},
"check": {
"noop": "Tính năng sao lưu Cloudron đã tắt. Hãy chắc rằng server được sao lưu bằng một biện pháp khác. Xem thông tin thêm tại https://docs.cloudron.io/backups/#storage-providers.",
"sameDisk": "Các bản sao lưu Cloudron hiện đang ở trên cùng ổ đĩa với server chạy Cloudron. Nếu ổ đĩa chứa đầy các bản sao lưu, Cloudron sẽ không hoạt động được. Sự cố trục trặc ổ đĩa cũng có thể làm mất dữ liệu hoàn toàn. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
"sameDisk": "Các bản sao lưu Cloudron đang ở trên cùng ổ đĩa với server chạy Cloudron. Việc này sẽ nguy hiểm và có thể dẫn đến mất dữ liệu nếu ổ đĩa bị trục trặc. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
},
"backupEdit": {
"preserved": {
@@ -641,8 +625,7 @@
"description": "Vẫn giữ bản sao lưu mặc kệ chính sách lưu giữ được định thế nào"
},
"title": "Chỉnh sửa Bản sao lưu",
"label": "Nhãn",
"remotePath": "Đường dẫn"
"label": "Nhãn"
}
},
"login": {
@@ -654,8 +637,7 @@
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
"loginTo": "Đăng nhập vào",
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau",
"loginWith": "Đăng nhập bằng Cloudron"
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
},
"setupAccount": {
"username": "Tên đăng nhập",
@@ -688,7 +670,7 @@
"enableAction": "Bật",
"setupDnsInfo": "Sử dụng lựa chọn này để cài đặt những bản ghi có liên quan đến email. Để trống lựa chọn này sẽ hữu ích cho việc tạo ra các hộp thư và <a href=\"{{ importEmailDocsLink }}\">nhập dữ liệu các mail đã có sẵn</a> trước khi đưa vào sử dụng.",
"setupDnsCheckbox": "Cài đặt các bản ghi DNS ngay",
"cloudflareInfo": "Tên miền cho mail server <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Hãy nhớ tắt proxy qua Cloudflare cho <code>{{ mailFqdn }}</code> và chỉnh về chế độ <code>DNS only</code>. Cần làm vậy vì Cloudflare không proxy được email.",
"cloudflareInfo": "Tên miền <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Xin chắc rằng proxy qua Cloudflare đã được tắt cho <code>{{ mailFqdn }}</code> và được chỉnh về chế độ<code>DNS only</code>. Việc này là cần thiết vì Cloudflare không proxy được email.",
"noProviderInfo": "Chưa cài đặt nhà cung cấp DNS. Những bản ghi DNS trong phần Trạng thái cần được cài đặt thủ công.",
"description": "Lựa chọn này sẽ cấu hình Cloudron để nhận mail cho <b>{{ domain }}</b>. Xem hướng dẫn để mở <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">những cổng cần thiết</a> cho Email Cloudron.",
"title": "Bật chế độ email cho {{ domain }}?"
@@ -874,7 +856,7 @@
"network": {
"configureIp": {
"providerGenericDescription": "Địa chỉ IP công cộng của server này sẽ được tự động dò tìm ra.",
"title": "Cấu hình nhà cung cấp IPv4"
"title": "Cấu hình nhà cung cấp IP"
},
"dyndns": {
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
@@ -897,8 +879,8 @@
"configure": "Cấu hình",
"interface": "Tên giao diện mạng",
"provider": "Nhà cung cấp",
"description": "Cloudron dùng địa chỉ IPv4 này để cài đặt các bản ghi A của DNS.",
"title": "IPv4",
"description": "Cloudron dùng địa chỉ IP này để cài đặt các bản ghi DNS.",
"title": "Địa chỉ IP",
"address": "Địa chỉ IP"
},
"title": "Mạng",
@@ -999,8 +981,7 @@
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
"title": "Cài đặt",
"acl": "Danh sách quản lý truy cập mail",
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL",
"virtualAllMail": "Thư mực \"Tất cả Thư\""
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL"
},
"domains": {
"testEmailTooltip": "Gửi mail thử",
@@ -1037,10 +1018,6 @@
},
"action": {
"queue": "Cho vào hàng chờ gửi sau"
},
"changeVirtualAllMailDialog": {
"description": "Thư mục \"Tất cả Thư\" là một thư mục chứa tất cả thư trong hộp thư của bạn. Thư mục này hữu dụng cho những mail client mà không hỗ trợ chức năng tìm kiếm thư mục xoay vòng.",
"title": "Thư mực \"Tất cả Thư\""
}
},
"branding": {
@@ -1055,9 +1032,7 @@
},
"logo": "Logo",
"cloudronName": "Tên cho Cloudron",
"title": "Thương hiệu",
"backgroundImage": "Hình nền trang đăng nhập",
"clearBackgroundImage": "Xoá"
"title": "Thương hiệu"
},
"eventlog": {
"time": "Thời gian",
@@ -1089,19 +1064,7 @@
"uninstalledApp": "App đã xoá",
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
},
"title": "Hệ thống",
"info": {
"activationTime": "Ngày tạo Cloudron",
"platformVersion": "Phiên bản hệ thống",
"title": "Thông tin",
"vendor": "Nhà cung cấp",
"product": "Sản phẩm",
"memory": "Bộ nhớ",
"uptime": "Thời gian online"
},
"graphs": {
"title": "Biểu đồ"
}
"title": "Hệ thống"
},
"support": {
"remoteSupport": {
@@ -1130,14 +1093,9 @@
"subscriptionRequired": "Phiếu hỗ trợ chỉ có trong những gói trả phí.",
"title": "Phiếu hỗ trợ",
"emailNotVerified": "Email tài khoản cloudron.io của bạn {{ email }} chưa được xác minh. Xin hãy xác minh mail trước để tạo phiếu hỗ trợ.",
"emailVerifyAction": "Xác minh ngay",
"typeBilling": "Vấn đề Hóa đơn"
"emailVerifyAction": "Xác minh ngay"
},
"title": "Hỗ trợ",
"help": {
"description": "Xin dùng những nguồn lực sau để được trợ giúp và hỗ trợ\n* [Diễn dàn Cloudron]({{ forumLink }}) - Vui lòng vào Mục Hỗ trợ & App cụ thể để đặt câu hỏi.\n* [HDSD & Kho kiến thức Cloudron]({{ docsLink }})\n* [Đóng gói App tùy chỉnh & API]({{ packagingLink }})\n",
"title": "Hỗ trợ"
}
"title": "Hỗ trợ"
},
"settings": {
"registryConfig": {
@@ -1190,15 +1148,17 @@
"changeScheduleAction": "Thay đổi lịch cập nhật",
"showLogsAction": "Hiển thị log",
"version": "Phiên bản hệ thống",
"currentSchedule": "Lịch cập nhật tự động hiện tại cho hệ thống và các app là",
"autoUpdateDisabled": "Cập nhật tự động cho hệ thống và các app <b>đã tắt</b>.",
"title": "Cập nhật"
},
"timezone": {
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>. Cài đặt này được dùng cho tác vụ sao lưu và cập nhật. Dấu thời gian hiện ở giao diện được hiển thị theo múi giờ của trình duyệt hiện dùng.",
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>.\nMúi giờ này được dùng cho việc lên lịch sao lưu và cập nhật hệ thống.",
"title": "Múi giờ"
},
"appstoreAccount": {
"subscriptionReactivateAction": "Kích hoạt lại gói đăng ký",
"subscriptionChangeAction": "Quản lý gói đăng ký",
"subscriptionChangeAction": "Thay đổi gói đăng ký",
"subscriptionSetupAction": "Nâng cấp Gói Cao cấp",
"subscriptionEndsAt": "Đã huỷ đăng ký và kết thúc vào",
"cloudronId": "Mã Cloudron ID",
@@ -1304,8 +1264,7 @@
"renameDialog": {
"rename": "Đổi tên",
"newName": "Tên mới",
"title": "Đổi tên {{ fileName }}",
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?"
"title": "Đổi tên {{ fileName }}"
},
"newFileDialog": {
"create": "Tạo",
@@ -1356,8 +1315,7 @@
"filePath": "Đường chỉ đến tập tin hay thư mục",
"title": "Tải xuống từ {{ name }}"
},
"title": "Màn hình terminal",
"uploadTo": "Tải lên {{ path }}"
"title": "Màn hình terminal"
},
"logs": {
"download": "Tải xuống tất cả log",
@@ -1426,13 +1384,7 @@
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
"porkbunSecretapikey": "Mã bí mật API",
"bunnyAccessKey": "Mã truy cập Bunny",
"porkbunApikey": "Key API",
"deSecToken": "Mã deSEC",
"dnsimpleAccessToken": "Mã truy cập",
"ovhAppSecret": "Mã bí mật App",
"ovhEndpoint": "Điểm Endpoint",
"ovhConsumerKey": "Mã Khách hàng",
"ovhAppKey": "Mã App"
"porkbunApikey": "Key API"
},
"subscriptionRequired": {
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
@@ -1480,14 +1432,13 @@
"firstTimeCollapseHeader": "Hướng dẫn cho lần cài đặt đầu tiên",
"openAction": "Mở {{ app }}",
"postInstallConfirmCheckbox": "Đã xem hướng dẫn",
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>.",
"checklist": "Danh sách kiểm tra cho Admin"
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>."
},
"uninstall": {
"uninstall": {
"uninstallAction": "Xoá",
"backupWarning": "Các bản sao lưu app sẽ không được xoá ngay mà sẽ dựa vào lịch trình sao lưu được định sẵn. Bạn có thể hồi sinh app từ một bản sao lưu hiện có bằng những <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">hướng dẫn sau đây</a>.",
"description": "Việc này sẽ xóa app ngay lập tức và tất cả dữ liệu. Trang sẽ không còn truy cập được sau khi xóa.",
"description": "Lựa chọn này sẽ gỡ cài đặt app ngay lập tức và xoá hết tất cả những dữ liệu liên quan. Trang web sẽ không còn truy cập được sau đó.",
"title": "Xoá"
},
"startStop": {
@@ -1540,24 +1491,23 @@
},
"updates": {
"auto": {
"enableAction": "Bật cập nhật tự động",
"disableAction": "Tắt cập nhật tự động",
"enableAction": "Bật chế độ cập nhật tự động",
"disableAction": "Tắt chế độ cập nhật tự động",
"disabled": "Cập nhật tự động hiện đang tắt.",
"enabled": "Cập nhật tự dộng đang được mở.",
"description": "Cloudron định kỳ kiểm tra <a href=\"{{ appStoreLink }}\" target=\"_blank\">Cửa hàng App </a> cho phiên bản app mới.",
"description": "Cloudron định kỳ kiểm tra Cửa hàng app cho các phiên bản cập nhật mới. Nếu bạn tắt chế độ cập nhật tự động, xin chắc rằng bạn cài đặt thủ công các cập nhật phiên bản mới.",
"title": "Cập nhật tự động"
},
"info": {
"updateAvailableAction": "Có phiên bản cập nhật mới",
"customAppUpdateInfo": "Tự động cập nhật không có sẵn cho các app tùy chỉnh.",
"customAppUpdateInfo": "Phiên bản mới không có sẵn cho các app tuỳ chỉnh",
"checkForUpdatesAction": "Kiểm tra cập nhật",
"lastUpdated": "Lần cuối cập nhật",
"packageVersion": "Phiên bản đóng gói",
"appId": "ID của app",
"description": "Tên app và phiên bản",
"title": "Thông tin app",
"repository": "Repo của bản đống gói",
"installedAt": "Được cài lúc"
"repository": "Repo của bản đống gói"
},
"noUpdates": "Không có phiên bản mới"
},
@@ -1636,14 +1586,14 @@
},
"resources": {
"cpu": {
"description": "Phần trăm CPU tối đa app có thể dùng",
"title": "Giới hạn CPU",
"setAction": "Nâng lên"
"description": "Phần trăm thời gian CPU dành cho app khi hệ thống đang chịu tải nặng.",
"title": "Chia phần trong CPU",
"setAction": "Cài đặt"
},
"memory": {
"resizeAction": "Chỉnh lại",
"error": "Hệ thống không chỉnh được giới hạn bộ nhớ này, hãy thử một giá trị thấp hơn.",
"description": "Bộ nhớ tối đa app có thể dùng",
"description": "Cloudron dành 50% giá trị này cho RAM và 50% còn lại cho swap.",
"title": "Giới hạn bộ nhớ"
}
},
@@ -1797,8 +1747,7 @@
},
"redis": {
"title": "Thiết lập Redis",
"enable": "Thiết lập app sử dụng Redis",
"disable": "Tắt Redis"
"enable": "Thiết lập app sử dụng Redis"
},
"addApplinkDialog": {
"title": "Thêm link app bên ngoài"
@@ -1812,12 +1761,6 @@
"upstreamUri": "Đường dẫn bên ngoài",
"label": "Nhãn",
"clearIconAction": "Xoá biểu tượng"
},
"infoTabTitle": "Thông tin",
"info": {
"notes": {
"title": "Ghi chú của Admin"
}
}
},
"volumes": {
@@ -1852,14 +1795,10 @@
},
"mountStatus": "Trạng thái mount",
"type": "Dạng",
"tooltipEdit": "Chỉnh Volume",
"tooltipEdit": "Chỉnh sửa Volume",
"localDirectory": "Thư mục trên máy",
"remountActionTooltip": "Mount Volume lại",
"mountType": "Dạng mount",
"editVolumeDialog": {
"title": "Chỉnh volume {{ name }}"
},
"editActionTooltip": "Chỉnh Volume"
"mountType": "Dạng mount"
},
"welcomeEmail": {
"inviteLinkAction": "Bắt đầu tạo tải khoản",
@@ -1883,8 +1822,7 @@
"es": "Tiếng Tây Ban Nha",
"ru": "Tiếng Nga",
"da": "Tiếng Đan Mạch",
"pt": "Tiếng Bồ Đào Nha",
"id": "Tiếng Indonesia"
"pt": "Tiếng Bồ Đào Nha"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
@@ -1921,7 +1859,7 @@
"topic": "Chúng tôi nhận thấy có một đăng nhập mới vào tài khoản Cloudron của bạn.",
"salutation": "Xin chào <%= user %>,",
"subject": "[<%= cloudron %>] Có đăng nhập mới vào tài khoản của bạn",
"notice": "Có một đăng nhập vào tài khoản Cloudron của bạn từ một thiết bị mới.",
"notice": "Chhungs tôi nhận thấy một đăng nhập trên tài khoản Cloudron của bạn từ một thiết bị mới.",
"action": "Nếu người đó là bạn, bạn có thể thoải mái bỏ qua email này. Nếu đó không phải là bạn, bạn nên đổi mật khẩu của bạn ngay bây giờ."
},
"supportConfig": {
+2
View File
@@ -798,6 +798,8 @@
},
"updates": {
"title": "更新",
"autoUpdateDisabled": "平台和应用的自动更新已 <b>停用</b>。",
"currentSchedule": "当前平台和应用的自动更新计划是",
"version": "平台版本",
"showLogsAction": "显示日志",
"changeScheduleAction": "修改计划",
+114 -181
View File
@@ -34,12 +34,6 @@
</h5>
</div>
<div class="modal-body">
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
</div>
</div>
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
@@ -63,7 +57,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#sftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#ftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
</div>
<div class="modal-body">
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
@@ -335,45 +329,32 @@
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.mountPoint }" ng-show="importBackup.provider === 'mountpoint'">
<label class="control-label" for="inputImportMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputImportMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
</div>
<!-- S3/Minio/SOS/GCS/SSHFS/CIFS/NFS/B2/Mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.prefix }" ng-show="importBackup.provider !== 'filesystem'">
<label class="control-label" for="inputImportBackupPrefix">{{ 'backups.configureBackupStorage.prefix' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.prefix" id="inputImportBackupPrefix" name="prefix" ng-disabled="importBackup.busy" placeholder="Prefix for backup file names">
<label class="control-label" for="inputConfigureMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="importBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="configureBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="importBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="importBackup.provider === 'cifs'">
<label>
<input type="checkbox" ng-model="importBackup.mountOptions.seal">{{ 'backups.configureBackupStorage.cifsSealSupport' | tr }}</input>
</label>
<label class="control-label" for="configureBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="configureBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="importBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-show="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'" ng-class="{ 'has-error': importBackup.error.diskPath }">
<label class="control-label" for="importBackupDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.diskPath" id="importBackupDiskPath" name="diskPath" ng-disabled="importBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'">
<label class="control-label" for="configureBackupDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.diskPath" id="configureBackupDiskPath" name="diskPath" ng-disabled="importBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'">
</div>
<!-- remotePath contains the prefix as well -->
@@ -384,26 +365,26 @@
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="importBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="importBackupUsername" name="username" ng-disabled="importBackup.busy">
<label class="control-label" for="configureBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="configureBackupUsername" name="username" ng-disabled="importBackup.busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="importBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="importBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
<label class="control-label" for="configureBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="configureBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="importBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="configureBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="importBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
<label class="control-label" for="configureBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': importBackup.error.region }" ng-show="importBackup.provider === 's3'">
@@ -626,17 +607,17 @@
<p class="text-small text-warning" ng-show="clone.domain.provider === 'noop' || clone.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((clone.subdomain ? clone.subdomain + '.' : '') + clone.domain.domain) }"></p>
<div class="has-error text-center" ng-show="clone.error.port">{{ clone.error.port }}</div>
<div ng-repeat="(env, info) in clone.portInfo">
<div ng-repeat="(env, info) in clone.portBindingsInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!clone.itemName{{$index}}.$dirty && clone.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portsEnabled[env]">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portBindingsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
<input type="number" class="form-control" ng-model="clone.ports[env]" ng-disabled="!clone.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="clone.portBindings[env]" ng-disabled="!clone.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="clone.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -720,7 +701,6 @@
<div class="row app-configure-links-container" ng-show="view">
<div class="col-sm-2">
<div class="app-configure-links">
<div ng-click="setView('info')" ng-class="{ 'active': view === 'info' }">{{ 'app.infoTabTitle' | tr }}</div>
<div ng-click="setView('display')" ng-class="{ 'active': view === 'display' }">{{ 'app.displayTabTitle' | tr }}</div>
<div ng-click="setView('location')" ng-class="{ 'active': view === 'location' }" ng-show="app.accessLevel === 'admin'">{{ 'app.locationTabTitle' | tr }}</div>
<div ng-click="setView('proxy')" ng-class="{ 'active': view === 'proxy' }" ng-show="app.type === APP_TYPES.PROXIED">Proxy</div>
@@ -739,101 +719,7 @@
<div ng-click="setView('uninstall')" ng-class="{ 'active': view === 'uninstall' }" ng-show="app.accessLevel === 'admin'">{{ 'app.uninstallTabTitle' | tr }}</div>
</div>
</div>
<div class="col-sm-8 card-container">
<div class="card" ng-show="view === 'info'">
<p>
<label class="control-label">{{ 'app.updates.info.title' | tr }}</label>
<a href="" class="pull-right" ng-click="info.showDoneChecklist = true" ng-show="info.hasOldChecklist && !info.showDoneChecklist">Show Checklist</a>
<a href="" class="pull-right" ng-click="info.showDoneChecklist = false" ng-show="info.showDoneChecklist">Hide Checklist</a>
</p>
<div ng-repeat="(key, item) in app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
<button class="btn btn-xs btn-default" style="margin-left: 10px;" ng-click="info.checklistAck(item, key)">Done</button>
</div>
</div>
<div ng-repeat="(key, item) in app.checklist" ng-show="info.showDoneChecklist">
<div class="checklist-item checklist-item-acknowledged" ng-show="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
<span class="text-muted text-small">{{ item.changedBy }} {{ item.changedAt | prettyDate }}</span>
</div>
</div>
<div style="margin-top: 10px"></div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span>{{ app.creationTime | prettyDate }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
</div>
</div>
<br/>
<p><label class="control-label">{{ 'app.info.notes.title' | tr }}</label><i ng-show="!info.notes.editing" class="info-edit-indicator fa fa-pencil-alt" ng-click="info.notes.edit()"></i></p>
<div class="row">
<div class="col-md-12" ng-show="!info.notes.busy">
<div ng-show="!info.notes.editing">
<div ng-show="info.notes.content" ng-bind-html="info.notes.content | markdown2html"></div>
<div ng-show="!info.notes.content" class="text-muted hand" ng-click="info.notes.edit()">{{ info.notes.placeholder }}</div>
</div>
<div ng-show="info.notes.editing" class="text-right">
<textarea id="adminNotesTextarea" ng-trim="false" style="white-space: pre-wrap; margin-bottom: 5px" ng-model="info.notes.content" class="form-control" rows="10"></textarea>
<button class="btn btn-default" ng-click="info.notes.dismiss()" ng-disabled="info.notes.busySave">{{ 'main.dialog.cancel' | tr }}</button>
<button class="btn btn-success" ng-click="info.notes.submit()" ng-disabled="info.notes.busySave"><i class="fa fa-circle-notch fa-spin" ng-show="info.notes.busySave"></i> {{ 'app.display.saveAction' | tr }}</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
</div>
</div>
</div>
<div class="card" ng-show="view === 'display'">
<div class="row">
<div class="col-md-12">
@@ -854,7 +740,7 @@
</div>
<div id="previewIcon" class="app-custom-icon" ng-click="display.showCustomIconSelector()">
<img ng-src="{{ display.iconUrl() || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)"/>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
<div class="overlay"></div>
</div>
<a href="" style="font-weight: normal;" ng-click="display.resetCustomIcon()">{{ 'app.display.iconResetAction' | tr }}</a>
<input type="file" id="iconFileInput" style="display: none" accept="image/png"/>
@@ -867,7 +753,8 @@
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="display.submit()" ng-disabled="(!display.icon.data && !displayForm.$dirty) || display.$invalid || display.busy"><i class="fa fa-circle-notch fa-spin" ng-show="display.busy"></i> {{ 'app.display.saveAction' | tr }}</button> </div>
<button class="btn btn-outline btn-primary pull-right" ng-click="display.submit()" ng-disabled="(!display.icon.data && !displayForm.$dirty) || display.$invalid || display.busy"><i class="fa fa-circle-notch fa-spin" ng-show="display.busy"></i> {{ 'app.display.saveAction' | tr }}</button>
</div>
</div>
</div>
@@ -932,18 +819,18 @@
</div>
<div class="has-error text-center" ng-show="location.error.port">{{ location.error.port }}</div>
<div ng-repeat="(env, info) in location.portInfo">
<div ng-repeat="(env, info) in location.portBindingsInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!portInfo_form.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portsEnabled[env]">
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
<span ng-show="info.portCount" style="display: block; float: right">{{ location.ports[env] }} to {{ location.ports[env] + info.portCount - 1 }} ({{ info.portCount }} ports)</span>
<span ng-show="info.portCount" style="display: block; float: right">({{ info.portCount }} ports) {{ location.portBindings[env] }} to {{ location.portBindings[env] + info.portCount - 1 }}</span>
</label>
<input type="number" class="form-control" ng-model="location.ports[env]" ng-disabled="!location.portsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<input type="number" class="form-control" ng-model="location.portBindings[env]" ng-disabled="!location.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -1108,12 +995,12 @@
<form role="form" name="resourcesForm" ng-submit="resources.submitMemoryLimit()" autocomplete="off">
<div class="form-group">
<label class="control-label" for="memoryLimit">{{ 'app.resources.memory.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ resources.memoryLimit | prettyBinarySize:'Default (256 MiB)' }}</b></label>
<p>{{ 'app.resources.memory.description' | tr }}</p>
<input type="range" id="memoryLimit" ng-model="resources.memoryLimit" step="134217728" min="{{ resources.memoryTicks[0] }}" max="{{ resources.memoryTicks[resources.memoryTicks.length-1] }}" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option ng-repeat="limit in resources.memoryTicks" value="{{ limit }}"></option>
</datalist>
<div style="padding: 0 10px;">
<slider id="memoryLimit" ng-model="resources.memoryLimit" step="134217728" tooltip="hide" ticks="resources.memoryTicks" ticks-snap-bounds="67108864"></slider>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy"/>
</form>
</div>
</div>
@@ -1122,31 +1009,34 @@
<span ng-show="resources.error.memoryLimit" class="text-danger">{{ 'app.resources.memory.error' | tr }}</span>
</div>
<div class="col-md-4 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitMemoryLimit()" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">{{ 'app.resources.memory.resizeAction' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitMemoryLimit()" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="resources.busy"></i> {{ 'app.resources.memory.resizeAction' | tr }}
</button>
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-12">
<form role="form" name="resourcesForm" ng-submit="resources.submitCpuQuota()" autocomplete="off">
<form role="form" name="resourcesForm" ng-submit="resources.submitCpuShares()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label" for="cpuQuota">{{ 'app.resources.cpu.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cpu-quota" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ resources.cpuQuota + ' %' }}</b></label>
<label class="control-label" for="cpuShares">{{ 'app.resources.cpu.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cpu-shares" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ (resources.cpuShares * 100 / 1024 | number:0) + ' %' }}</b></label>
<p>{{ 'app.resources.cpu.description' | tr }}</p>
<input type="range" id="cpuQuota" ng-model="resources.cpuQuota" step="1" min="1" max="100"/>
<datalist id="cpuQuotaTicks">
<option value="25"></option>
<option value="50"></option>
<option value="75"></option>
</datalist>
<div style="padding: 0 10px;">
<slider id="cpuShares" ng-model="resources.cpuShares" ticks="[32, 256, 512, 768, 1024]" step="32" ticks-snap-bounds="32" min="32" max="1024" tooltip="hide"></slider>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="resources.cpuShares === resources.currentCpuShares || resourcesForm.$invalid || resources.busyCpuShares"/>
</fieldset>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitCpuQuota()" ng-disabled="resources.cpuQuota === resources.currentCpuQuota || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">{{ 'app.resources.cpu.setAction' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitCpuShares()" ng-disabled="resources.cpuShares === resources.currentCpuShares || resourcesForm.$invalid || resources.busyCpuShares || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="resources.busyCpuShares"></i> {{ 'app.resources.cpu.setAction' | tr }}
</button>
</div>
</div>
</div>
@@ -1466,8 +1356,7 @@
<thead>
<tr>
<th class="col-md-2">{{ 'eventlog.time' | tr }}</th> <!-- "minutes ago" takes space -->
<th class="col-md-2">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-6">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-8">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2" style="text-align: right;">
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showPrevPage()" ng-disabled="eventlog.busy || eventlog.currentPage <= 1"><i class="fa fa-angle-double-left"></i></button>
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showNextPage()" ng-disabled="eventlog.busy || eventlog.perPage > eventlog.eventLogs.length"><i class="fa fa-angle-double-right"></i></button>
@@ -1477,14 +1366,10 @@
<tbody ng-repeat="eventLog in eventlog.eventLogs">
<tr ng-click="eventlog.showDetails(eventLog)" class="hand">
<td><span uib-tooltip="{{ eventLog.raw.creationTime | prettyLongDate }}" class="arrow">{{ eventLog.raw.creationTime | prettyDate }}</span></td>
<td>{{ eventLog.source }}</td>
<td style="word-wrap: anywhere;" colspan="2" ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="eventlog.activeEventLog === eventLog">
<td colspan="4">
<p ng-show="eventLog.raw.source.ip">Source IP: <code>{{ eventLog.raw.source.ip }}</code></p>
<pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre>
</td>
<td colspan="3"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
</tr>
</tbody>
</table>
@@ -1555,28 +1440,76 @@
</div>
<div class="card" ng-show="view === 'updates'">
<p><label class="control-label">{{ 'app.updatesTabTitle' | tr }}</label></p>
<p><label class="control-label">{{ 'app.updates.info.title' | tr }}</label></p>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<div class="row">
<div class="col-md-12">
<p>
<span ng-bind-html="'app.updates.auto.description' | tr:{ appStoreLink: 'https://www.cloudron.io/store/index.html' }"></span>
<span ng-show="app.appStoreId && updates.enableAutomaticUpdate" class="text-success">{{ 'app.updates.auto.enabled' | tr }}</span>
<span ng-show="app.appStoreId && !updates.enableAutomaticUpdate" class="text-danger">{{ 'app.updates.auto.disabled' | tr }}</span>
<span ng-show="!app.appStoreId" class="text-danger">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</p>
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="row" ng-show="app.appStoreId">
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="!updates.busyCheck && !(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version)">{{ 'app.updates.noUpdates' | tr }}</span>
</div>
<div class="col-md-6 text-right">
<button type="button" class="btn" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" ng-hide="!(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update') || app.taskId" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="updates.check()" ng-disabled="updates.busyCheck"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
</div>
</div>
<hr/>
<div class="row" ng-show="!app.appStoreId">
<div class="col-md-12">
<button class="btn pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="updates.enableAutomaticUpdate ? 'btn-danger' : 'btn-success'" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ updates.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<!-- check for updates button is always visible -->
<button class="btn btn-default btn-outline pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-click="updates.check()" ng-disabled="updates.busyCheck || !app.appStoreId"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
<!-- show update button only if update available -->
<button class="btn pull-right" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update' && !app.taskId" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
<span class="text-danger pull-right">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</div>
</div>
<div ng-show="app.appStoreId" class="row">
<div class="col-md-12">
<label class="control-label">{{ 'app.updates.auto.title' | tr }}</label>
<p>{{ 'app.updates.auto.description' | tr }}</p>
</div>
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="app.enableAutomaticUpdate">{{ 'app.updates.auto.enabled' | tr }}</span>
<span class="text-danger" ng-hide="app.enableAutomaticUpdate">{{ 'app.updates.auto.disabled' | tr }}</span>
</div>
<div class="col-md-6">
<button class="btn btn-primary pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="{ 'btn-danger': app.enableAutomaticUpdate }" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ app.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
</div>
</div>
</div>
@@ -1603,7 +1536,7 @@
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<!-- <td><div class="hand clipboard" data-clipboard-text="{{ backup.id }}" uib-tooltip="{{ copyBackupIdDone ? ('main.clipboard.copied' | tr) : ('main.clipboard.clickToCopyBackupId' | tr) }}" tooltip-placement="right"><i class="fa fa-copy"></i></div></td> -->
<td ng-click="backupDetails.show(backup)" class="hand"><div>v{{ backup.packageVersion }}</div></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td class="text-center" style="vertical-align: bottom">
<div class="dropdown">
<button class="btn btn-xs btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+71 -162
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular, localStorage, document, FileReader */
/* global angular */
/* global $ */
/* global async */
/* global RSTATES */
@@ -136,73 +136,6 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
$scope.info = {
showDoneChecklist: false,
hasOldChecklist: false,
notes: {
busy: true,
busySave: false,
editing: false,
content: '',
placeholder: 'Add admin notes here...',
edit: function () {
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = true;
setTimeout(function () { document.getElementById('adminNotesTextarea').focus(); }, 1);
},
dismiss: function () {
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
},
submit: function () {
$scope.info.notes.busySave = true;
// skip saving if unchanged from postInstall
if ($scope.info.notes.content === $scope.app.manifest.postInstallMessage) {
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
return;
}
Client.configureApp($scope.app.id, 'notes', { notes: $scope.info.notes.content }, function (error) {
if (error) return console.error('Failed to save notes.', error);
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
});
});
}
},
show: function () {
$scope.info.hasOldChecklist = !!Object.keys($scope.app.checklist).find((k) => { return $scope.app.checklist[k].acknowledged; });
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
$scope.info.notes.busy = false;
},
checklistAck(item, key) {
item.acknowledged = true;
// item.acknowledged = !item.acknowledged;
Client.ackAppChecklistItem($scope.app.id, key, item.acknowledged, function (error) {
if (error) return console.error('Failed to ack checklist item.', error);
$scope.info.hasOldChecklist = true;
refreshApp($scope.app.id);
});
}
};
$scope.display = {
busy: false,
error: {},
@@ -305,9 +238,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
redirectDomains: [],
aliasDomains: [],
ports: {},
portsEnabled: {},
portInfo: {},
portBindings: {},
portBindingsEnabled: {},
portBindingsInfo: {},
addRedirectDomain: function (event) {
event.preventDefault();
@@ -369,18 +302,18 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
});
$scope.location.portInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
$scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
for (var env in $scope.location.portInfo) {
for (var env in $scope.location.portBindingsInfo) {
if (app.portBindings && app.portBindings[env]) {
$scope.location.ports[env] = app.portBindings[env].hostPort;
$scope.location.portsEnabled[env] = true;
$scope.location.portBindings[env] = app.portBindings[env];
$scope.location.portBindingsEnabled[env] = true;
} else {
$scope.location.ports[env] = $scope.location.portInfo[env].defaultValue || 0;
$scope.location.portsEnabled[env] = false;
$scope.location.portBindings[env] = $scope.location.portBindingsInfo[env].defaultValue || 0;
$scope.location.portBindingsEnabled[env] = false;
}
}
},
@@ -400,11 +333,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports
var ports = {};
for (var env in $scope.location.ports) {
if ($scope.location.portsEnabled[env]) {
ports[env] = $scope.location.ports[env];
// only use enabled ports from portBindings
var portBindings = {};
for (var env in $scope.location.portBindings) {
if ($scope.location.portBindingsEnabled[env]) {
portBindings[env] = $scope.location.portBindings[env];
}
}
@@ -412,7 +345,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
overwriteDns: !!overwriteDns,
subdomain: $scope.location.subdomain,
domain: $scope.location.domain.domain,
ports: ports,
portBindings: portBindings,
secondaryDomains: secondaryDomains,
redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
aliasDomains: $scope.location.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };})
@@ -607,52 +540,43 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busy: false,
currentMemoryLimit: 0,
memoryLimit: 0, // RAM
memoryLimit: 0,
memoryTicks: [],
currentCpuQuota: 0,
cpuQuota: 0,
busyCpuShares: false,
currentCpuShares: 0,
cpuShares: 0,
show: function () {
var app = $scope.app;
$scope.resources.busy = true;
$scope.resources.error = {};
$scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024);
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.currentCpuShares = $scope.resources.cpuShares = app.cpuShares;
Client.memory(function (error, result) {
if (error) console.error(error);
Client.getAppLimits(app.id, function (error, limits) {
if (error) return console.error(error);
// create ticks starting from manifest memory limit. the memory limit here is just RAM
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
$scope.resources.memoryTicks = [];
// we max system memory and current app memory for the case where the user configured the app on another server with more resources
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
// code below ensure we atleast have 2 ticks to keep the slider usable
$scope.resources.memoryTicks.push(startTick); // start tick
for (var i = startTick * 2; i < nearest256m; i *= 2) {
$scope.resources.memoryTicks.push(i);
var npow2 = Math.pow(2, Math.ceil(Math.log(limits.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
if (i >= (app.manifest.memoryLimit/1024/1024 || 0)) $scope.resources.memoryTicks.push(i * 1024 * 1024);
}
if (app.manifest.memoryLimit && $scope.resources.memoryTicks[0] !== app.manifest.memoryLimit) {
$scope.resources.memoryTicks.unshift(app.manifest.memoryLimit);
}
$scope.resources.memoryTicks.push(nearest256m); // end tick
});
// for firefox widget update
$timeout(function() {
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota = app.cpuQuota;
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.busy = false;
}, 500);
},
submitMemoryLimit: function () {
$scope.resources.busy = true;
$scope.resources.error = {};
const tmp = parseInt($scope.resources.memoryLimit);
const memoryLimit = tmp === $scope.resources.memoryTicks[0] ? 0 : tmp;
Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit }, function (error) {
var memoryLimit = $scope.resources.memoryLimit === $scope.resources.memoryTicks[0] ? 0 : $scope.resources.memoryLimit;
Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit: memoryLimit }, function (error) {
if (error && error.statusCode === 400) {
$scope.resources.busy = false;
$scope.resources.error.memoryLimit = true;
@@ -670,19 +594,19 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
});
},
submitCpuQuota: function () {
$scope.resources.busy = true;
submitCpuShares: function () {
$scope.resources.busyCpuShares = true;
$scope.resources.error = {};
Client.configureApp($scope.app.id, 'cpu_quota', { cpuQuota: parseInt($scope.resources.cpuQuota) }, function (error) {
Client.configureApp($scope.app.id, 'cpu_shares', { cpuShares: $scope.resources.cpuShares }, function (error) {
if (error) return Client.error(error);
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota;
$scope.resources.currentCpuShares = $scope.resources.cpuShares;
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$timeout(function () { $scope.resources.busy = false; }, 1000);
$timeout(function () { $scope.resources.busyCpuShares = false; }, 1000);
});
});
},
@@ -790,11 +714,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.storage.error.storageVolumePrefix = error.message;
$scope.storage.busyDataDir = false;
return;
} else if (error) {
Client.error(error);
$scope.storage.busyDataDir = false;
return;
}
if (error) return Client.error(error);
$scope.storageDataDirForm.$setPristine();
@@ -1290,27 +1211,21 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busyUpdate: false,
busyAutomaticUpdates: false,
skipBackup: false,
enableAutomaticUpdate: true,
show: function () {
$scope.updates.skipBackup = false;
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
},
toggleAutomaticUpdates: function () {
$scope.updates.busyAutomaticUpdates = true;
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) {
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.app.enableAutomaticUpdate }, function (error) {
if (error) return Client.error(error);
refreshApp($scope.app.id, function (error) {
if (error) console.error(error);
$timeout(function () {
console.log($scope.updates.enableAutomaticUpdate, $scope.app.enableAutomaticUpdate);
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
$scope.updates.busyAutomaticUpdates = false;
}, 2000);
$scope.updates.busyAutomaticUpdates = false;
});
});
},
@@ -1444,7 +1359,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
provider: '',
bucket: '',
prefix: '',
mountPoint: '', // for mountpoint
mountPoint: '',
accessKeyId: '',
secretAccessKey: '',
gcsKey: { keyFileName: '', content: '' },
@@ -1455,17 +1370,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
remotePath: '',
password: '',
encryptedFilenames: true,
mountOptions: {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
user: '',
seal: true,
port: 22,
privateKey: ''
},
mountOptions: {}, // host, port, username, password, remoteDir, diskPath, user, privateKey
encrypted: false, // helps with ng-required when backupConfig is read from file
clearForm: function () {
@@ -1483,7 +1388,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.importBackup.password = '';
$scope.importBackup.encryptedFilenames = true;
$scope.importBackup.remotePath = '';
$scope.importBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
$scope.importBackup.mountOptions = {};
},
submit: function () {
@@ -1503,7 +1408,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
backupConfig.bucket = $scope.importBackup.bucket;
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.prefix = '';
backupConfig.accessKeyId = $scope.importBackup.accessKeyId;
backupConfig.secretAccessKey = $scope.importBackup.secretAccessKey;
@@ -1550,7 +1455,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.importBackup.bucket;
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.prefix = '';
try {
var serviceAccountKey = JSON.parse($scope.importBackup.gcsKey.content);
backupConfig.projectId = serviceAccountKey.project_id;
@@ -1570,10 +1475,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
} else if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
backupConfig.mountOptions = $scope.importBackup.mountOptions;
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.prefix = '';
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.mountPoint = $scope.importBackup.mountPoint;
backupConfig.mountOptions = {};
backupConfig.mountPoint = $scope.mountPoint;
} else if (backupConfig.provider === 'filesystem') {
var parts = remotePath.split('/');
remotePath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
@@ -1797,9 +1702,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
needsOverwrite: false,
overwriteDns: false,
ports: {},
portsEnabled: {},
portInfo: {},
portBindings: {},
portBindingsInfo: {},
portBindingsEnabled: {},
show: function (backup) {
var app = $scope.app;
@@ -1821,11 +1726,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
$scope.clone.portInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
$scope.clone.portBindingsInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
// set default ports
for (var env in $scope.clone.portInfo) {
$scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0;
$scope.clone.portsEnabled[env] = true;
for (var env in $scope.clone.portBindingsInfo) {
$scope.clone.portBindings[env] = $scope.clone.portBindingsInfo[env].defaultValue || 0;
$scope.clone.portBindingsEnabled[env] = true;
}
$('#appCloneModal').modal('show');
@@ -1842,11 +1747,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports
var finalPorts = {};
for (var env in $scope.clone.ports) {
if ($scope.clone.portsEnabled[env]) {
finalPorts[env] = $scope.clone.ports[env];
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.clone.portBindings) {
if ($scope.clone.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.clone.portBindings[env];
}
}
@@ -1854,7 +1759,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
subdomain: $scope.clone.subdomain,
domain: $scope.clone.domain.domain,
secondaryDomains: secondaryDomains,
ports: finalPorts,
portBindings: finalPortBindings,
backupId: $scope.clone.backup.id,
overwriteDns: $scope.clone.overwriteDns
};
@@ -2251,12 +2156,16 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var backupConfig;
try {
backupConfig = JSON.parse(result.target.result);
if (backupConfig.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
backupConfig.remotePath = backupConfig.backupFolder + '/' + backupConfig.remotePath;
let prefix = backupConfig.prefix;
backupConfig.prefix = ''; // so it can clear the form as well when we apply keys below
if (backupConfig.provider === 'filesystem') { // patch the remotePath to have the full path
backupConfig.remotePath = (prefix ? prefix + '/' : '') + backupConfig.backupFolder + '/' + backupConfig.remotePath;
delete backupConfig.backupFolder;
} else {
backupConfig.remotePath = (prefix ? prefix + '/' : '') + backupConfig.remotePath;
}
} catch (e) {
console.error('Unable to parse backup config', e);
console.error('Unable to parse backup config');
return;
}
@@ -2282,7 +2191,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
if ($routeParams.view) { // explicit route in url bar
$scope.setView($routeParams.view, true /* skipViewShow */);
} else { // default
$scope.setView($scope.app.error ? 'repair' : 'info', true /* skipViewShow */);
$scope.setView($scope.app.error ? 'repair' : 'display', true /* skipViewShow */);
}
function done() {
+34 -110
View File
@@ -4,34 +4,28 @@
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appPostInstallConfirm.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
<div class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}<br/>
<span class="text-muted text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<h5 class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}
<span class="app-info-meta text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<br/>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl" class="text-small"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<br/>
</div>
</h5>
</div>
<div class="modal-body">
<!--
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
-->
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
<div style="margin-top: 10px; margin-bottom: 5px;" ng-show="pendingChecklistItems(appPostInstallConfirm.app)">
<label class="control-label">{{ 'app.appInfo.checklist' | tr }}</label>
</div>
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
</div>
</div>
<div ng-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
</div>
<div class="modal-footer">
<div class="form-group pull-left">
<input type="checkbox" id="appsPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appsPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ 'https://' + appPostInstallConfirm.app.fqdn }}" target="_blank" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
</div>
</div>
</div>
@@ -63,7 +57,7 @@
</div>
<div id="previewIcon" class="app-custom-icon" ng-click="applinksEdit.showCustomIconSelector()">
<img ng-src="{{ applinksEdit.iconUrl() || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)"/>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
<div class="overlay"></div>
</div>
<a href="" style="font-weight: normal;" ng-click="applinksEdit.resetCustomIcon()">{{ 'app.applinks.clearIconAction' | tr }}</a> - <span class="text-small">{{ 'app.applinks.clearIconDescription' | tr }}</span>
<input type="file" id="applinksEditIconFileInput" style="display: none" accept="image/png"/>
@@ -136,36 +130,34 @@
</div>
</div>
<h1 class="view-header" ng-show="installedApps.length > 0" style="padding-right: 0;">
<h1 class="view-header" ng-show="installedApps.length > 0">
{{ 'apps.title' | tr }}
<div class="view-header-search-bar">
<form class="form-inline">
<div class="input-group">
<input type="text" class="form-control" style="width: 300px" placeholder="{{ 'apps.searchPlaceholder' | tr }} ( / )" id="appSearch" ng-model="appSearch"/>
<input type="text" class="form-control" style="width: 300px" placeholder="{{ 'apps.searchPlaceholder' | tr }}" id="appSearch" ng-model="appSearch"/>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-class="{ 'active': showFilter }" ng-click="toggleFilter()"><i class="fas fa-filter"></i></button>
<button class="btn btn-default" type="button" ng-class="{ 'active': showFilter, 'btn-warning': showFilter || selectedTags.length || selectedState.state || !selectedGroup._unset || !selectedDomain._alldomains }" ng-click="showFilter = !showFilter"><i class="fas fa-filter"></i></button>
</span>
</div>
<button class="btn btn-default" type="button" ng-click="toggleView()"><i class="fas" ng-class="{ 'fa-list': view === VIEWS.GRID, 'fa-grip': view === VIEWS.LIST }"></i></button>
</form>
</div>
<div ng-show="showFilter" class="view-header-filter-bar">
<form class="form-inline">
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && groups.length > 1" ms-header="{{ selectedGroup.name }}" options="group.name for group in groups" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedState" ng-show="user.isAtLeastAdmin" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ selectedState }}" options="state.label for state in states" data-multiple="false"></multiselect>
<multiselect ng-model="selectedTags" ng-show="user.isAtLeastAdmin && tags.length > 0" ms-header="{{ 'apps.tagsFilterHeaderAll' | tr }}" ms-selected="{{ 'apps.tagsFilterHeader' | tr:{ tags: selectedTags.join(', ') } }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedDomain" data-compare-by="domain" ms-selected="{{ selectedDomain.domain }}" options="domain.domain for domain in filterDomains" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<button class="btn btn-warning" ng-disabled="!selectedTags.length && !selectedState.state && selectedGroup._unset && selectedDomain._alldomains" ng-click="clearAllFilter()">{{ 'apps.filter.clearAll' | tr }}</button>
</form>
</div>
</h1>
<div ng-show="showFilter" class="view-header-filter-bar">
<form class="form-inline">
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && groups.length > 1" ms-header="{{ selectedGroup.name }}" options="group.name for group in groups" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedState" ng-show="user.isAtLeastAdmin" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ selectedState }}" options="state.label for state in states" data-multiple="false"></multiselect>
<multiselect ng-model="selectedTags" ng-show="user.isAtLeastAdmin && tags.length > 0" ms-header="{{ 'apps.tagsFilterHeaderAll' | tr }}" ms-selected="{{ 'apps.tagsFilterHeader' | tr:{ tags: selectedTags.join(', ') } }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedDomain" data-compare-by="domain" ms-selected="{{ selectedDomain.domain }}" options="domain.domain for domain in filterDomains" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<!-- <button class="btn btn-primary" ng-disabled="!selectedTags.length && !selectedState.state && selectedGroup._unset && selectedDomain._alldomains" ng-click="clearAllFilter()">{{ 'apps.filter.clearAll' | tr }}</button> -->
</form>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-grid" ng-show="view === VIEWS.GRID">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}" tooltip-append-to-body="true">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/info" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div class="app-grid">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:labelOrFQDN">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/display" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div ng-show="app.type === APP_TYPES.LINK && isOperator(app)" ng-click="applinksEdit.show(app)" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></div>
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank">
<div class="grid-item-top">
@@ -198,80 +190,12 @@
</div>
</a>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-click="showAppConfigure(app, 'updates')" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
</div>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-click="showAppConfigure(app, 'updates')" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')" uib-tooltip="Update Available">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
<div class="app-checklist-badge" ng-click="showAppConfigure(app, 'info')" ng-show="pendingChecklistItems(app)">
{{ pendingChecklistItems(app) }}
</div>
</div>
</div>
<div class="app-list card card-large" ng-show="view === VIEWS.LIST">
<table class="table table-hover" style="margin: 0;">
<thead>
<tr>
<th style="width: 32px" class="hand" ng-click="setOrderBy('status')"><i ng-show="orderBy === 'status'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 32px">&nbsp;</th>
<th style="width: 35%" class="hand" ng-click="setOrderBy('location')">{{ 'app.display.label' | tr }} <i ng-show="orderBy === 'location'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 30%" class="hand hide-mobile" ng-click="setOrderBy('app')">App Title<i ng-show="orderBy === 'app'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 32px">&nbsp;</th>
<th style="width: 32px" class="hand hide-mobile text-center" ng-click="setOrderBy('sso')"><i class="fas fa-user-lock"></i> <i ng-show="orderBy === 'sso'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width:160px" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr class="app-list-item" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter:orderByReverse" uib-tooltip="{{ app | appProgressMessage }}">
<td class="elide-table-cell">
<i class="fa fa-circle" ng-class="app | installationStateClass" uib-tooltip="{{ app | installationStateLabel }}"></i>
</td>
<td class="elide-table-cell app-list-app-link-cell">
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank" class="app-list-app-link">
<img ng-src="{{ app.iconUrl || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)" class="app-list-item-icon"/>
</a>
</td>
<td class="elide-table-cell app-list-app-link-cell">
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank" class="app-list-app-link">
<span style="font-size: 16px;">{{ app.label || app.subdomain || app.fqdn }}</span><br/>
<span class="text-muted text-small">{{ app.fqdn.indexOf('http') === 0 ? app.fqdn : 'https://'+app.fqdn }}</span>
</a>
</td>
<td class="elide-table-cell hide-mobile">{{ app.manifest.title || 'App Link' }}</td>
<td class="elide-table-cell hide-mobile text-center">
<a class="badge badge-danger" ng-show="pendingChecklistItems(app)" ng-href="#/app/{{ app.id}}/info">{{ pendingChecklistItems(app) }}</a>
</td>
<td class="elide-table-cell hide-mobile text-center">
<div ng-show="app.type !== APP_TYPES.LINK">
<i class="fa-brands fa-openid" ng-show="app.ssoAuth && app.manifest.addons.oidc" uib-tooltip="{{ 'apps.auth.openid' | tr }}"></i>
<i class="fas fa-user" ng-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" uib-tooltip="{{ 'apps.auth.sso' | tr }}"></i>
<i class="far fa-user" ng-show="!app.ssoAuth && !app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.nosso' | tr }}"></i>
<i class="fas fa-envelope" ng-show="app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.email' | tr }}"></i>
</div>
</td>
<td class="elide-table-cell text-right">
<span ng-show="isOperator(app)">
<a class="btn btn-xs btn-success" style="padding: 1px 7px;" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')" ng-href="#/app/{{ app.id}}/updates" uib-tooltip="Update Available"><i class="fa fa-arrow-up"></i></a>
<div class="btn-group btn-group-xs" role="group">
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.LINK" ng-href="{{ '/frontend/logs.html?appId=' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}"><i class="fas fa-align-left"></i></a>
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.PROXIED && app.type !== APP_TYPES.LINK" ng-href="{{ '/frontend/terminal.html?id=' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}"><i class="fa fa-terminal"></i></a>
<a class="btn btn-xs btn-default" ng-show="app.manifest.addons.localstorage" ng-href="{{ '/frontend/filemanager.html#/home/app/' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
</div>
<button class="btn btn-xs btn-default" ng-show="app.type === APP_TYPES.LINK" ng-click="applinksEdit.show(app)" uib-tooltip="Configure Applink"><i class="fa fa-cog"></i></button>
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.LINK" ng-href="#/app/{{ app.id}}/info" uib-tooltip="Configure App"><i class="fa fa-cog"></i></a>
</span>
</td>
</tr>
</tbody>
</table>
<br/>
<div>
{{ 'apps.apps.count' | tr:{ count: (installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch).length } }}
</div>
</div>
</div>
+14 -80
View File
@@ -4,7 +4,6 @@
/* global $:false */
/* global APP_TYPES */
/* global onAppClick */
/* global localStorage, document, FileReader */
angular.module('Application').controller('AppsController', ['$scope', '$translate', '$interval', '$location', 'Client', function ($scope, $translate, $interval, $location, Client) {
var ALL_DOMAINS_DOMAIN = { _alldomains: true, domain: 'All Domains' }; // dummy record for the single select filter
@@ -33,15 +32,6 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.showFilter = false;
$scope.filterActive = false;
$scope.VIEWS = {
GRID: 'grid',
LIST: 'list'
};
$scope.view = $scope.VIEWS.GRID;
$scope.orderBy = 'location'; // or app, status, sso
$scope.orderByReverse = false;
$scope.allUsers = [];
$scope.allGroups = [];
@@ -55,59 +45,11 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
if (tr['app.states.updateAvailable']) $scope.states[3].label = tr['app.states.updateAvailable'];
});
$scope.pendingChecklistItems = function (app) {
if (!app.checklist) return 0;
return Object.keys(app.checklist).filter(function (key) { return !app.checklist[key].acknowledged; }).length;
};
$scope.setOrderBy = function (by) {
if (by === $scope.orderBy) {
$scope.orderByReverse = !$scope.orderByReverse;
} else {
$scope.orderBy = by;
$scope.orderByReverse = false;
}
localStorage.appsOrderBy = by;
if ($scope.orderByReverse) localStorage.appsOrderByReverse = true;
else localStorage.removeItem('appsOrderByReverse');
};
// for sorting/grouping
$scope.orderByFilter = function (item) {
if ($scope.orderBy === 'app') return item.manifest.title || 'App Link';
if ($scope.orderBy === 'status') return item.installationState + '-' + item.runState;
if ($scope.orderBy === 'sso') return item.sso;
// for sorting of the app grid items
$scope.labelOrFQDN = function (item) {
return item.label || item.fqdn;
};
$scope.setView = function (view) {
if (view !== $scope.VIEWS.LIST && view !== $scope.VIEWS.GRID) return;
$scope.view = view;
localStorage.appsView = view;
};
$scope.toggleView = function () {
$scope.view = $scope.view === $scope.VIEWS.GRID ? $scope.VIEWS.LIST : $scope.VIEWS.GRID;
localStorage.appsView = $scope.view;
};
$scope.toggleFilter = function () {
$scope.showFilter = !$scope.showFilter;
if ($scope.showFilter) localStorage.appsShowFilter = true;
else localStorage.removeItem('appsShowFilter');
// clear on hide
if (!$scope.showFilter) {
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
}
};
$scope.$watch('selectedTags', function (newVal, oldVal) {
if (newVal === oldVal) return;
@@ -137,13 +79,22 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.onAppClick = function (app, $event) { onAppClick(app, $event, $scope.isOperator(app), $scope); };
$scope.clearAllFilter = function () {
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
};
$scope.appPostInstallConfirm = {
app: {},
message: '',
confirmed: false,
show: function (app) {
$scope.appPostInstallConfirm.app = app;
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
$scope.appPostInstallConfirm.confirmed = false;
$('#appsPostInstallConfirmModal').modal('show');
@@ -151,6 +102,8 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
},
submit: function () {
if (!$scope.appPostInstallConfirm.confirmed) return;
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
@@ -303,12 +256,6 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
});
});
$scope.setView(localStorage.appsView);
$scope.orderBy = localStorage.appsOrderBy || 'location';
$scope.orderByReverse = !!localStorage.appsOrderByReverse;
$scope.showFilter = !!localStorage.appsShowFilter;
if (!$scope.user.isAtLeastAdmin) return;
// load local settings and apply tag filter
@@ -358,7 +305,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
// setup all the dialog focus handling
['applinksAddModal', 'applinksEditModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('autofocus]:first').focus();
$(this).find("[autofocus]:first").focus();
});
});
@@ -369,17 +316,4 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
});
$('.modal-backdrop').remove();
function keyboardHandler(event) {
if (event.key === '/') {
document.getElementById('appSearch').focus();
event.preventDefault();
}
}
document.addEventListener('keydown', keyboardHandler);
$scope.$on('$destroy', function () {
document.removeEventListener('keydown', keyboardHandler);
});
}]);
+5 -5
View File
@@ -69,17 +69,17 @@
</div>
<div class="has-error text-center" ng-show="appInstall.error.port">{{ appInstall.error.port }}</div>
<div ng-repeat="(env, info) in appInstall.portInfo">
<div ng-repeat="(env, info) in appInstall.portBindingsInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!appInstallForm.itemName{{$index}}.$dirty && appInstall.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portsEnabled[env]">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portBindingsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
<input type="number" class="form-control" ng-model="appInstall.ports[env]" ng-disabled="!appInstall.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="appInstall.portBindings[env]" ng-disabled="!appInstall.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="appInstall.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -407,7 +407,7 @@
<center>
<a href="" ng-click="appstoreLogin.setupType = 'signup'" ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.setupType = 'login'" ng-show="appstoreLogin.setupType === 'signup' || appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">Use a setup token</a></span>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">use a setup token</a></span>
</center>
</div>
</div>
+17 -18
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false, document, window, localStorage, FileReader */
/* global angular:false */
/* global $:false */
/* global async */
/* global ERROR */
@@ -10,7 +10,7 @@
angular.module('Application').controller('AppStoreController', ['$scope', '$translate', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $translate, $location, $timeout, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.HOST_PORT_MIN = 1;
$scope.HOST_PORT_MIN = 1024;
$scope.HOST_PORT_MAX = 65535;
$scope.ready = false;
@@ -122,8 +122,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: '',
domain: null, // object and not the string
secondaryDomains: {},
ports: {},
portsEnabled: {},
portBindings: {},
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
@@ -148,7 +147,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appInstall.subdomain = '';
$scope.appInstall.domain = null;
$scope.appInstall.secondaryDomains = {};
$scope.appInstall.ports = {};
$scope.appInstall.portBindings = {};
$scope.appInstall.state = 'appInfo';
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
@@ -177,13 +176,13 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
var app = $scope.appInstall.app;
var DEFAULT_MEMORY_LIMIT = 1024 * 1024 * 256;
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM+Swap
var used = Client.getInstalledApps().reduce(function (prev, cur) {
if (cur.runState === RSTATES.STOPPED) return prev;
return prev + (cur.memoryLimit || cur.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT);
}, 0);
var totalMemory = $scope.memory.memory * 2;
var totalMemory = ($scope.memory.memory + $scope.memory.swap) * 1.5;
var available = (totalMemory || 0) - used;
var enoughResourcesAvailable = (available - needed) >= 0;
@@ -219,9 +218,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
$scope.appInstall.portInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.ports = {}; // This holds the env:port pair
$scope.appInstall.portsEnabled = {}; // This holds the enabled/disabled flag
$scope.appInstall.portBindingsInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
var manifest = app.manifest;
$scope.appInstall.optionalSso = !!manifest.optionalSso;
@@ -233,8 +232,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
// set default ports
var allPorts = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts);
for (var env in allPorts) {
$scope.appInstall.ports[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portsEnabled[env] = true;
$scope.appInstall.portBindings[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portBindingsEnabled[env] = true;
}
$('#appInstallModal').modal('show');
@@ -254,11 +253,11 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
// only use enabled ports from ports
var finalPorts = {};
for (var env in $scope.appInstall.ports) {
if ($scope.appInstall.portsEnabled[env]) {
finalPorts[env] = $scope.appInstall.ports[env];
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
if ($scope.appInstall.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appInstall.portBindings[env];
}
}
@@ -274,7 +273,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: $scope.appInstall.subdomain || '',
domain: $scope.appInstall.domain.domain,
secondaryDomains: secondaryDomains,
ports: finalPorts,
portBindings: finalPortBindings,
accessRestriction: finalAccessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
+30 -30
View File
@@ -32,10 +32,8 @@
</div>
<br/>
<p class="text-muted">{{ 'backups.backupDetails.list' | tr:{ appCount: backupDetails.backup.contents.length } }}:</p>
<span ng-repeat="content in backupDetails.backup.contents | orderBy:['label','fqdn']">
<a ng-if="content.fqdn" ng-href="/#/app/{{content.id}}/backups">{{ content.label || content.fqdn }}</a>
<a ng-if="!content.fqdn" ng-href="/#/eventlog?search={{content.id}}">{{ content.id }}</a>
<span ng-hide="$last">,</span>
<span ng-repeat="app in backupDetails.backup.contents | orderBy:['label','fqdn']">
<a ng-href="/#/app/{{app.id}}/backups">{{ app.label || app.fqdn }}</a><span ng-hide="$last">,</span>
</span>
</div>
<div class="modal-footer">
@@ -390,46 +388,48 @@
</div>
<a href="" ng-click="configureBackup.advancedVisible = true" ng-hide="configureBackup.advancedVisible">{{ 'backups.configureBackupStorage.advancedSettings' | tr }}</a>
<div uib-collapse="!configureBackup.advancedVisible">
<div uib-collapse="!configureBackup.advancedVisible">
<div class="form-group">
<label class="control-label" for="sliderConfigureBackupMemoryLimit">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.memoryLimitDescription' | tr }}</p>
<input type="range" id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" step="{{ 256*1024*1024 }}" min="{{ MIN_MEMORY_LIMIT }}" max="{{ MAX_MEMORY_LIMIT }}" />
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" tooltip="hide" step="268435456" ticks="configureBackup.memoryTicks"></slider>
</div>
</div>
<div class="form-group" ng-show="s3like(configureBackup.provider)">
<label class="control-label" for="sliderConfigureBackupUploadPartSize">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyBinarySize:'Default (50 MiB)' }}</b></label>
<label class="control-label">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyBinarySize:'Default (50 MiB)' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.uploadPartSizeDescription' | tr }}</p>
<input type="range" id="sliderConfigureBackupUploadPartSize" ng-model="configureBackup.uploadPartSize" list="uploadPartSizeTicks" step="{{ 1024*1024 }}" min="{{ 1024*1024 }}" max="{{ 1024*1024*1024 }}" />
<datalist id="uploadPartSizeTicks">
<option value="{{ 1024*1024 }}"></option>
<option value="{{ 64*1024*1024 }}"></option>
<option value="{{ 128*1024*1024 }}"></option>
<option value="{{ 256*1024*1024 }}"></option>
<option value="{{ 512*1024*1024 }}"></option>
<option value="{{ 1024*1024*1024 }}"></option>
</datalist>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupUploadPartSize" ng-model="configureBackup.uploadPartSize" step="1048576" tooltip="hide" ticks="configureBackup.uploadPartSizeTicks" ticks-snap-bounds="2097152"></slider>
</div>
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && configureBackup.provider !== 'noop'">
<label class="control-label" for="sliderConfigureBackupSyncConcurrency">{{ 'backups.configureBackupStorage.uploadConcurrency' | tr }}: <b>{{ configureBackup.syncConcurrency }}</b></label>
<label class="control-label">{{ 'backups.configureBackupStorage.uploadConcurrency' | tr }}: <b>{{ configureBackup.syncConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.uploadConcurrencyDescription' | tr }}</p>
<input type="range" id="sliderConfigureBackupSyncConcurrency" ng-model="configureBackup.syncConcurrency" step="10" min="10" max="200" />
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupSyncConcurrency" ng-model="configureBackup.syncConcurrency" tooltip="hide" min="10" max="200" step="10"></slider>
</div>
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label" for="sliderConfigureBackupDownloadConcurrency">{{ 'backups.configureBackupStorage.downloadConcurrency' | tr }}: <b>{{ configureBackup.downloadConcurrency }}</b></label>
<label class="control-label">{{ 'backups.configureBackupStorage.downloadConcurrency' | tr }}: <b>{{ configureBackup.downloadConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.downloadConcurrencyDescription' | tr }}</p>
<input type="range" id="sliderConfigureBackupDownloadConcurrency" ng-model="configureBackup.downloadConcurrency" step="10" min="10" max="200" />
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.downloadConcurrency" tooltip="hide" min="10" max="200" step="10"></slider>
</div>
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label" for="sliderConfigureBackupCopyConcurrency">{{ 'backups.configureBackupStorage.copyConcurrency' | tr }}: <b>{{ configureBackup.copyConcurrency }}</b></label>
<label class="control-label">{{ 'backups.configureBackupStorage.copyConcurrency' | tr }}: <b>{{ configureBackup.copyConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.copyConcurrencyDescription' | tr }}
<span ng-show="configureBackup.provider === 'digitalocean-spaces'">{{ 'backups.configureBackupStorage.copyConcurrencyDigitalOceanNote' | tr }}</span>
</p>
<input type="range" id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.copyConcurrency" step="10" min="10" max="500" />
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.copyConcurrency" tooltip="hide" min="10" max="500" step="10"></slider>
</div>
</div>
</div> <!-- advanced -->
@@ -476,7 +476,7 @@
<span>{{ prettyProviderName(backupConfig.provider) }}</span>
</div>
</div>
<div class="row" ng-show="backupConfig.provider !== 'noop'">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.location.location' | tr }}</span>
</div>
@@ -542,20 +542,20 @@
</div>
<div class="card" style="margin-bottom: 15px;">
<p ng-bind-html=" 'backups.schedule.description' | tr "></p>
<p>{{ 'backups.schedule.description' | tr }}</p>
<div class="row">
<div class="col-xs-4">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
</div>
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
<div class="col-xs-6 text-right">
<span>{{ prettyBackupSchedule(backupPolicy.currentPolicy.schedule) }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-4">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<div class="col-xs-6 text-right">
<span>{{ prettyBackupRetention(backupPolicy.currentPolicy.retention) }}</span>
</div>
</div>
@@ -605,7 +605,7 @@
<tr ng-repeat="backup in backups">
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<td ng-click="backupDetails.show(backup)" class="hand">v{{ backup.packageVersion }}</td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand">
<span ng-show="!backup.contents.length">{{ 'backups.listing.noApps' | tr }}</span>
<span ng-show="backup.contents.length">{{ 'backups.listing.appCount' | tr:{ appCount: backup.contents.length } }}</span>
+30 -32
View File
@@ -2,21 +2,18 @@
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO */
/* global document, window, FileReader */
angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.SECRET_PLACEHOLDER = SECRET_PLACEHOLDER;
$scope.MIN_MEMORY_LIMIT = 1024 * 1024 * 1024; // 1 GB
$scope.MAX_MEMORY_LIMIT = $scope.MIN_MEMORY_LIMIT; // set later
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.memory = null; // { memory, swap }
$scope.mountStatus = null; // { state, message }
$scope.manualBackupApps = [];
$scope.currentTimeZone = '';
$scope.backupConfig = {};
$scope.backups = [];
@@ -466,6 +463,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
memoryTicks: [],
memoryLimit: $scope.MIN_MEMORY_LIMIT,
uploadPartSizeTicks: [],
uploadPartSize: 50 * 1024 * 1024,
copyConcurrency: '',
downloadConcurrency: '',
@@ -479,7 +477,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
username: '',
password: '',
diskPath: '',
seal: true,
seal: false,
user: '',
port: 22,
privateKey: ''
@@ -508,7 +506,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.copyConcurrency = $scope.configureBackup.provider === 's3' ? 500 : 10;
$scope.configureBackup.disk = null;
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: false, user: '', port: 22, privateKey: '' };
},
show: function () {
@@ -542,14 +540,25 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks;
$scope.configureBackup.chown = $scope.backupConfig.chown;
const limits = $scope.backupConfig.limits || {};
var limits = $scope.backupConfig.limits || {};
$scope.configureBackup.memoryLimit = Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT);
$scope.configureBackup.memoryLimit = limits.memoryLimit ? Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT) : $scope.MIN_MEMORY_LIMIT;
$scope.configureBackup.uploadPartSize = limits.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024);
$scope.configureBackup.downloadConcurrency = limits.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10);
$scope.configureBackup.syncConcurrency = limits.syncConcurrency || ($scope.backupConfig.provider === 's3' ? 20 : 10);
$scope.configureBackup.copyConcurrency = limits.copyConcurrency || ($scope.backupConfig.provider === 's3' ? 500 : 10);
var totalMemory = Math.max(($scope.memory.memory + $scope.memory.swap) * 1.5, 2 * 1024 * 1024);
$scope.configureBackup.memoryTicks = [ $scope.MIN_MEMORY_LIMIT ];
for (var i = 1024; i <= totalMemory/1024/1024; i *= 2) {
$scope.configureBackup.memoryTicks.push(i * 1024 * 1024);
}
$scope.configureBackup.uploadPartSizeTicks = [ 5 * 1024 * 1024 ];
for (var j = 32; j <= 1 * 1024; j *= 2) { // 5 GB is max for s3. but let's keep things practical for now. we upload 3 parts in parallel
$scope.configureBackup.uploadPartSizeTicks.push(j * 1024 * 1024);
}
var mountOptions = $scope.backupConfig.mountOptions || {};
$scope.configureBackup.mountOptions = {
host: mountOptions.host || '',
@@ -598,7 +607,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
schedulePattern: $scope.backupConfig.schedulePattern,
retentionPolicy: $scope.backupConfig.retentionPolicy,
limits: {
memoryLimit: parseInt($scope.configureBackup.memoryLimit),
memoryLimit: $scope.configureBackup.memoryLimit,
},
};
if ($scope.configureBackup.password) {
@@ -654,8 +663,6 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
backupConfig.limits.uploadPartSize = parseInt($scope.configureBackup.uploadPartSize);
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.configureBackup.bucket;
backupConfig.prefix = $scope.configureBackup.prefix;
@@ -706,10 +713,12 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks;
}
backupConfig.limits.uploadPartSize = $scope.configureBackup.uploadPartSize;
if (backupConfig.format === 'rsync') {
backupConfig.limits.downloadConcurrency = parseInt($scope.configureBackup.downloadConcurrency);
backupConfig.limits.syncConcurrency = parseInt($scope.configureBackup.syncConcurrency);
backupConfig.limits.copyConcurrency = parseInt($scope.configureBackup.copyConcurrency);
backupConfig.limits.downloadConcurrency = $scope.configureBackup.downloadConcurrency;
backupConfig.limits.syncConcurrency = $scope.configureBackup.syncConcurrency;
backupConfig.limits.copyConcurrency = $scope.configureBackup.copyConcurrency;
}
Client.setBackupConfig(backupConfig, function (error) {
@@ -757,7 +766,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.error.backupFolder = true;
}
} else {
$scope.configureBackup.error.generic = error.message;
console.error('Unable to change provider.', error);
}
return;
@@ -785,23 +794,14 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
});
$scope.backups.forEach(function (backup) {
backup.contents = []; // { id, label, fqdn }
backup.contents = [];
backup.dependsOn.forEach(function (appBackupId) {
const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return; // for example, 'mail'
const app = appsById[match[1]];
if (app) {
backup.contents.push({
id: app.id,
label: app.label,
fqdn: app.fqdn
});
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return;
if (match[1].indexOf('.') !== -1) { // newer backups have fqdn in them
if (appsByFqdn[match[1]]) backup.contents.push(appsByFqdn[match[1]]);
} else {
backup.contents.push({
id: match[1],
label: null,
fqdn: null
});
if (appsById[match[1]]) backup.contents.push(appsById[match[1]]);
}
});
});
@@ -850,8 +850,6 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if (error) console.error(error);
$scope.memory = memory;
var nearestGb = Math.ceil($scope.memory.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
$scope.MAX_MEMORY_LIMIT = nearestGb;
fetchBackups();
getBackupConfig();
@@ -889,7 +887,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
// setup all the dialog focus handling
['configureBackupModal', 'editBackupModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
$(this).find("[autofocus]:first").focus();
});
});
+20 -32
View File
@@ -1,27 +1,27 @@
<!-- Modal change avatar -->
<div class="modal fade" id="avatarChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'branding.changeLogo.title' | tr }}</h4>
</div>
<div class="modal-body branding-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{ avatarChange.avatarUrl() }}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'branding.changeLogo.title' | tr }}</h4>
</div>
<div class="modal-body branding-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{ avatarChange.avatarUrl() }}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
<br/>
<br/>
<br/>
<br/>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.setAvatar()"> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.setAvatar()"> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
@@ -49,19 +49,7 @@
</div>
<div class="branding-avatar" ng-click="avatarChange.showChangeAvatar()">
<img ng-src="{{ about.avatarUrl() }}"/>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
</div>
<div class="form-group">
<div>
<label class="control-label">{{ 'branding.backgroundImage' | tr }}</label>
<div class="branding-background" ng-click="background.selectNew()">
<img ng-src="{{ background.url() }}" onerror="this.src = '/img/background-image-placeholder.svg'"/>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" ng-show="!background.cleared" ng-click="background.clear()">{{ 'branding.clearBackgroundImage' | tr }}</a>
<input type="file" id="backgroundFileInput" style="display: none" accept="image/*"/>
<div class="overlay"></div>
</div>
</div>
@@ -73,7 +61,7 @@
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="about.submit()" ng-disabled="false && (!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"><i class="fa fa-circle-notch fa-spin" ng-show="about.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="about.submit()" ng-disabled="(!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"><i class="fa fa-circle-notch fa-spin" ng-show="about.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
+8 -98
View File
@@ -134,76 +134,6 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
fr.readAsDataURL(event.target.files[0]);
};
$scope.background = {
enabled: false,
file: null,
src: null,
cleared: false,
newImageFile: null,
cacheBusting: Date.now(),
url() {
if ($scope.background.cleared) return '/img/background-image-placeholder.svg';
else if ($scope.background.src) return $scope.background.src;
else return `${Client.apiOrigin}/api/v1/cloudron/background?${$scope.background.cacheBusting}`;
},
selectNew() {
document.getElementById('backgroundFileInput').click();
},
submit(callback) {
if ($scope.background.cleared) {
Client.changeCloudronBackground(null, callback);
} else if ($scope.background.newImageFile) {
Client.changeCloudronBackground($scope.background.newImageFile, callback);
} else {
callback();
}
},
clear() {
$scope.background.cleared = true;
}
};
document.getElementById('backgroundFileInput').onchange = function (event) {
const fr = new FileReader();
fr.onload = function () {
const image = new Image();
image.onload = function () {
// convert and scale to webp max 4k
const maxWidth = 4096;
const canvas = document.createElement('canvas');
if (image.naturalWidth > maxWidth) {
canvas.width = maxWidth;
canvas.height = (image.naturalHeight / image.naturalWidth) * maxWidth;
} else {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
}
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
$scope.$apply(function () {
const myImage = new File([blob], 'background.webp', { type: blob.type });
$scope.background.cleared = false;
$scope.background.newImageFile = myImage;
$scope.background.src = URL.createObjectURL(myImage);
});
}, 'image/webp');
$scope.background.file = event.target.files[0];
};
image.src = fr.result;
};
fr.readAsDataURL(event.target.files[0]);
};
$scope.about = {
busy: false,
error: {},
@@ -211,7 +141,7 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
avatar: null,
avatarBlob: null,
avatarUrl() {
avatarUrl: function () {
if ($scope.about.avatar) {
return $scope.about.avatar.data || $scope.about.avatar.url;
} else {
@@ -219,22 +149,12 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
}
},
refresh() {
refresh: function () {
$scope.about.cloudronName = $scope.config.cloudronName;
$scope.about.avatar = null;
Client.hasCloudronBackground(function (error, result) {
if (error) return console.error('Failed to get background state.', error);
$scope.background.enabled = result;
$scope.background.file = null;
$scope.background.src = null;
$scope.background.newImageFile = null;
$scope.background.cacheBusting = Date.now();
});
},
submit() {
submit: function () {
$scope.about.error.name = null;
$scope.about.busy = true;
@@ -262,22 +182,12 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
return;
}
$scope.background.submit(function (error) {
if (error) {
$scope.about.busy = false;
console.error('Unable to change background.', error);
return;
}
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.refresh();
$scope.about.busy = false;
});
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.busy = false;
});
});
});
-6
View File
@@ -183,12 +183,6 @@
<input type="text" class="form-control" ng-model="domainConfigure.vultrToken" name="vultrToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'vultr'">
</div>
<!-- deSEC -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'desec'">
<label class="control-label">{{ 'domains.domainDialog.deSecToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.deSecToken" name="deSecToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'desec'">
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecom'">
<label class="control-label">{{ 'domains.domainDialog.nameComUsername' | tr }}</label>
+1 -8
View File
@@ -41,12 +41,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Custom Wildcard Certificate', value: 'fallback' },
];
// keep in sync with setup.js
// keep in sync with setupdns.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'deSEC', value: 'desec' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
@@ -70,7 +69,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
case 'bunny': return 'Bunny';
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare';
case 'desec': return 'deSEC';
case 'digitalocean': return 'DigitalOcean';
case 'dnsimple': return 'dnsimple';
case 'gandi': return 'Gandi LiveDNS';
@@ -260,7 +258,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
deSecToken: '',
nameComToken: '',
nameComUsername: '',
namecheapUsername: '',
@@ -324,7 +321,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.dnsimpleAccessToken = domain.provider === 'dnsimple' ? domain.config.accessToken : '';
$scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : '';
$scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : '';
$scope.domainConfigure.deSecToken = domain.provider === 'desec' ? domain.config.token : '';
$scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : '';
$scope.domainConfigure.cloudflareToken = domain.provider === 'cloudflare' ? domain.config.token : '';
$scope.domainConfigure.cloudflareEmail = domain.provider === 'cloudflare' ? domain.config.email : '';
@@ -406,8 +402,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
data.token = $scope.domainConfigure.hetznerToken;
} else if (provider === 'vultr') {
data.token = $scope.domainConfigure.vultrToken;
} else if (provider === 'desec') {
data.token = $scope.domainConfigure.deSecToken;
} else if (provider === 'gandi') {
data.token = $scope.domainConfigure.gandiApiKey;
} else if (provider === 'godaddy') {
@@ -509,7 +503,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.porkbunApikey = '';
$scope.domainConfigure.porkbunSecretapikey = '';
$scope.domainConfigure.vultrToken = '';
$scope.domainConfigure.deSecToken = '';
$scope.domainConfigure.tlsConfig.provider = 'letsencrypt-prod';
$scope.domainConfigure.zoneName = '';
+107 -53
View File
@@ -108,74 +108,78 @@
<h4 class="modal-title">{{ 'email.editMailboxDialog.title' | tr:{ name: mailboxes.edit.name, domain: domain.domain } }}</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
<div class="control-label">
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<form name="mailboxedit_form" role="form" ng-submit="mailboxes.edit.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group">
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
<div class="control-label">
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
</div>
<div class="form-group aliases">
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="form-group aliases">
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control input-sm" ng-model="alias.name">
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control input-sm" ng-model="alias.name" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="incomingDomain in incomingDomains">
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
</li>
</ul>
<div class="input-group-btn">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="incomingDomain in incomingDomains">
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
</div>
</div>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
<div ng-show="mailboxes.edit.aliases.length === 0">
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
</div>
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
</div>
</div>
<div ng-show="mailboxes.edit.aliases.length === 0">
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
<div class="form-group">
<label for="storageQuota">
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<div style="padding: 0 10px;">
<slider id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" ticks-snap-bounds="1000000000" tooltip="hide" ticks="storageQuotaTicks"></slider>
</div>
</div>
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
</div>
</div>
<div class="form-group">
<label for="storageQuota">
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
<datalist id="storageQuotaTicks">
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
</datalist>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
</label>
</div>
<input class="hide" type="submit" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -205,6 +209,44 @@
</div>
</div>
<!-- Modal import mailboxes -->
<div class="modal fade" id="mailboxImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'email.mailboxImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!mailboxImport.done">
<div ng-show="!mailboxImport.busy">
<p ng-bind-html=" 'email.mailboxImportDialog.description' | tr:{ docsLink: 'https://cloudron.io/documentation/email/#import-mailboxes' } "></p>
<input type="file" style="display: none;" id="mailboxImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="mailboxImport.openFileInput()">{{ 'email.mailboxImportDialog.fileInput' | tr }}</button>
<br/>
<br/>
<p class="text-danger" ng-show="mailboxImport.error.file">{{ mailboxImport.error.file }}</p>
<p class="text-info" ng-show="mailboxImport.mailboxes.length">{{ 'email.mailboxImportDialog.mailboxesFound' | tr:{ count: mailboxImport.mailboxes.length } }}</p>
</div>
<div ng-show="mailboxImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailboxImport.percent }}%"></div>
</div>
</div>
<div ng-show="mailboxImport.done">
<p>{{ 'email.mailboxImportDialog.success' | tr:{ count: mailboxImport.success } }}</p>
<div ng-show="mailboxImport.error.import.length">
<p class="text-danger">{{ 'email.mailboxImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in mailboxImport.error.import"><b>{{ tmp.mailbox.name }}@{{ tmp.mailbox.domain }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="mailboxImport.import()" ng-show="!mailboxImport.done" ng-disabled="mailboxImport.busy || !mailboxImport.mailboxes.length"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxImport.busy"></i> {{ 'email.mailboxImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal add mailinglist -->
<div class="modal fade" id="mailinglistAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -343,7 +385,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="https://docs.cloudron.io/email/" target="_blank">{{ 'app.docsAction' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="domain.mailConfig.enabled ? howToConnectInfo.show() : null">{{ 'email.config.clientConfiguration' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="howToConnectInfo.show()">{{ 'email.config.clientConfiguration' | tr }}</a></li>
</ul>
</div>
</h3>
@@ -374,6 +416,18 @@
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="btn-group pull-right" style="margin-left: 5px;">
<button class="btn btn-default" ng-click="mailboxImport.show()" uib-tooltip="{{ 'email.incoming.mailboxes.importTooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'email.incoming.mailboxes.exportTooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="mailboxExport('csv')">{{ 'email.incoming.mailboxes.mailboxExport.csv' | tr }}</a></li>
<li><a href="" ng-click="mailboxExport('json')">{{ 'email.incoming.mailboxes.mailboxExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
</h3>
</div>
+168 -2
View File
@@ -42,7 +42,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$scope.domain = null;
$scope.adminDomain = null;
$scope.mailUsage = null;
$scope.storageQuotaTicks = [ 500*1000*1000, 5*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
$scope.storageQuotaTicks = [ 500*1000*1000, 1*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
$scope.expectedDnsRecords = {
mx: { },
@@ -377,6 +377,172 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
};
$scope.mailboxImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
mailboxes: [],
reset: function () {
$scope.mailboxImport.busy = false;
$scope.mailboxImport.error = null;
$scope.mailboxImport.mailboxes = [];
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
},
handleFileChanged: function () {
$scope.mailboxImport.reset();
var fileInput = document.getElementById('mailboxImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.mailboxImport.mailboxes = [];
var mailboxes = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.mailboxImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 4) {
$scope.mailboxImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 4' };
return;
}
mailboxes.push({
name: items[0].trim(),
domain: items[1].trim(),
owner: items[2].trim(),
ownerType: items[3].trim(),
});
}
} else {
try {
mailboxes = JSON.parse(reader.result).map(function (mailbox) {
return {
name: mailbox.name,
domain: mailbox.domain,
owner: mailbox.owner,
ownerType: mailbox.ownerType
};
});
} catch (e) {
console.error('Failed to parse mailboxes.', e);
$scope.mailboxImport.error = { file: 'Imported file is not valid JSON' };
}
}
$scope.mailboxImport.mailboxes = mailboxes;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.mailboxImport.reset();
// named so no duplactes
document.getElementById('mailboxImportFileInput').addEventListener('change', $scope.mailboxImport.handleFileChanged);
$('#mailboxImportModal').modal('show');
},
openFileInput: function () {
$('#mailboxImportFileInput').click();
},
import: function () {
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
$scope.mailboxImport.error = { import: [] };
$scope.mailboxImport.busy = true;
var processed = 0;
async.eachSeries($scope.mailboxImport.mailboxes, function (mailbox, callback) {
var owner = $scope.owners.find(function (o) { return o.display === mailbox.owner && o.type === mailbox.ownerType; }); // owner may not exist
if (!owner) {
$scope.mailboxImport.error.import.push({ error: new Error('Could not detect owner'), mailbox: mailbox });
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
return callback();
}
Client.addMailbox(mailbox.domain, mailbox.name, owner.id, mailbox.ownerType, function (error) {
if (error) $scope.mailboxImport.error.import.push({ error: error, mailbox: mailbox });
else ++$scope.mailboxImport.success;
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.mailboxImport.busy = false;
$scope.mailboxImport.done = true;
if ($scope.mailboxImport.success) $scope.mailboxes.refresh();
});
}
};
$scope.mailboxExport = function (type) {
// FIXME only does first 10k mailboxes
Client.listMailboxes($scope.domain.domain, '', 1, 10000, function (error, result) {
if (error) {
Client.error('Failed to list mailboxes. Full error in the webinspector.');
return console.error('Failed to list mailboxes.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
return {
name: mailbox.name,
domain: mailbox.domain,
owner: owner ? owner.display : '', // this meta property is set when we get the user list
ownerType: owner ? owner.type : '',
active: mailbox.active,
aliases: mailbox.aliases
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
var aliases = mailbox.aliases.map(function (a) { return a.name + '@' + a.domain; }).join(' ');
return [ mailbox.name, mailbox.domain, owner ? owner.display : '', owner ? owner.type : '', aliases, mailbox.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = $scope.domain.domain.replaceAll('.','_') + '-mailboxes.' + type;
document.body.appendChild(a);
a.click();
});
};
$scope.mailboxes = {
mailboxes: [],
search: '',
@@ -473,7 +639,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
ownerType: $scope.mailboxes.edit.owner.type,
active: $scope.mailboxes.edit.active,
enablePop3: $scope.mailboxes.edit.enablePop3,
storageQuota: $scope.mailboxes.edit.storageQuotaEnabled ? parseInt($scope.mailboxes.edit.storageQuota) : 0,
storageQuota: $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0,
messagesQuota: 0
};
+6 -6
View File
@@ -10,8 +10,8 @@
<br>
<form name="maxEmailSizeChangeForm" role="form" novalidate ng-submit="maxEmailSize.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label" for="maxEmailSizeInput">{{ 'emails.changeMailSizeDialog.size' | tr }} <b>{{ maxEmailSize.size | prettyDecimalSize }}</b></label>
<input type="range" id="maxEmailSizeInput" ng-model="maxEmailSize.size" step="1000000" min="1000000" max="1000000000" />
<label class="control-label">{{ 'emails.changeMailSizeDialog.size' | tr }} <b>{{ maxEmailSize.size | prettyDecimalSize }}</b></label>
<slider ng-model="maxEmailSize.size" tooltip="hide" min="1000000" max="1000000000" step="1000000"></slider>
</div>
<input class="ng-hide" type="submit"/>
</form>
@@ -101,8 +101,8 @@
<div class="form-group">
<label class="control-label">{{ 'emails.spamFilterDialog.blacklisteAddresses' | tr }}</label>
<p class="small">{{ 'emails.spamFilterDialog.blacklisteAddressesInfo' | tr }}</p>
<div class="has-error" ng-show="spamConfig.error.blocklist">{{ spamConfig.error.blocklist }}</div>
<textarea ng-model="spamConfig.blocklist" placeholder="{{ 'emails.spamFilterDialog.blacklisteAddressesPlaceholder' | tr }}" name="blocklist" class="form-control" ng-class="{ 'has-error': !spamConfigChangeForm.blocklist.$dirty && spamConfig.error.blocklist }" rows="4"></textarea>
<div class="has-error" ng-show="spamConfig.error.blacklist">{{ spamConfig.error.blacklist }}</div>
<textarea ng-model="spamConfig.blacklist" placeholder="{{ 'emails.spamFilterDialog.blacklisteAddressesPlaceholder' | tr }}" name="blacklist" class="form-control" ng-class="{ 'has-error': !spamConfigChangeForm.blacklist.$dirty && spamConfig.error.blacklist }" rows="4"></textarea>
</div>
<div class="form-group">
<label class="control-label">{{ 'emails.spamFilterDialog.customRules' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#custom-spam-filtering-rules" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
@@ -262,7 +262,7 @@
</h3>
</div>
<div class="card" ng-show="user.isAtLeastAdmin">
<div class="card">
<div class="row">
<div class="col-md-7">
<p ng-bind-html="'emails.changeDomainDialog.description' | tr"></p>
@@ -341,7 +341,7 @@
<span class="text-muted">{{ 'emails.settings.spamFilter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ 'emails.settings.spamFilterOverview' | tr:{ blacklistCount: spamConfig.acl.blocklist.length } }} <a href="" ng-click="spamConfig.show()"><i class="fa fa-edit text-small"></i></a></span>
<span>{{ 'emails.settings.spamFilterOverview' | tr:{ blacklistCount: spamConfig.acl.blacklist.length } }} <a href="" ng-click="spamConfig.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.solrFts' | tr }}</span>
+9 -9
View File
@@ -79,7 +79,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.mailLocation.message = '';
$scope.mailLocation.errorMessage = '';
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error) {
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error, result) {
if (error) {
console.error(error);
$scope.mailLocation.errorMessage = error.message;
@@ -121,7 +121,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
submit: function () {
$scope.maxEmailSize.busy = true;
Client.setMaxEmailSize(parseInt($scope.maxEmailSize.size), function (error) {
Client.setMaxEmailSize($scope.maxEmailSize.size, function (error) {
$scope.maxEmailSize.busy = false;
if (error) return console.error(error);
@@ -250,11 +250,11 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig = {
busy: false,
error: {},
acl: { allowlist: [], blocklist: [] },
acl: { whitelist: [], blacklist: [] },
customConfig: '',
config: '',
blocklist: '', // currently, we don't support allowlist because it requires user to understand a bit more of what he is doing
blacklist: '', // currently, we don't support whitelist because it requires user to understand a bit more of what he is doing
refresh: function () {
Client.getSpamCustomConfig(function (error, config) {
@@ -274,7 +274,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig.busy = false;
$scope.spamConfig.error = {};
$scope.spamConfig.blocklist = $scope.spamConfig.acl.blocklist.join('\n');
$scope.spamConfig.blacklist = $scope.spamConfig.acl.blacklist.join('\n');
$scope.spamConfig.config = $scope.spamConfig.customConfig;
$scope.spamConfigChangeForm.$setUntouched();
@@ -287,13 +287,13 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig.busy = true;
$scope.spamConfig.error = {};
var blocklist = $scope.spamConfig.blocklist.split('\n').filter(function (l) { return l !== ''; });
var blacklist = $scope.spamConfig.blacklist.split('\n').filter(function (l) { return l !== ''; });
Client.setSpamAcl({ blocklist: blocklist, allowlist: [] }, function (error) {
Client.setSpamAcl({ blacklist: blacklist, whitelist: [] }, function (error) {
if (error) {
$scope.spamConfig.busy = false;
$scope.spamConfig.error.blocklist = error.message;
$scope.spamConfigChangeForm.blocklist.$setPristine();
$scope.spamConfig.error.blacklist = error.message;
$scope.spamConfigChangeForm.blacklist.$setPristine();
return;
}
+3 -6
View File
@@ -32,8 +32,8 @@
<thead>
<tr>
<th class="col-md-2">{{ 'eventlog.time' | tr }}</th>
<th class="col-md-2">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-8">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-3">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-7">{{ 'eventlog.details' | tr }}</th>
</tr>
</thead>
<tbody ng-repeat="eventLog in eventLogs">
@@ -43,10 +43,7 @@
<td ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="activeEventLog === eventLog">
<td colspan="4">
<p ng-show="eventLog.raw.source.ip">Source IP: <code>{{ eventLog.raw.source.ip }}</code></p>
<pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre>
</td>
<td colspan="4"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
</tr>
</tbody>
</table>
-2
View File
@@ -72,7 +72,6 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'user.remove', value: 'user.remove' },
{ name: 'user.transfer', value: 'user.transfer' },
{ name: 'user.update', value: 'user.update' },
{ name: 'userdirectory.profileconfig.update', value: 'userdirectory.profileconfig.update '},
{ name: 'volume.add', value: 'volume.add' },
{ name: 'volume.update', value: 'volume.update' },
{ name: 'volume.remove', value: 'volume.update' },
@@ -143,7 +142,6 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
};
Client.onReady(function () {
$scope.search = $location.search().search || ''; // sent from the backups view when app is deleted
fetchEventLogs();
});
+1 -1
View File
@@ -171,7 +171,7 @@
</div>
</div>
<div class="row" ng-show="sysinfo.provider !== 'noop'">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
-1
View File
@@ -11,7 +11,6 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
// keep in sync with sysinfo.js
$scope.sysinfoProvider = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
+3 -3
View File
@@ -1,4 +1,4 @@
<div class="content content-large">
<div class="content">
<div class="text-left">
<h1>{{ 'notifications.title' | tr }}
@@ -15,7 +15,7 @@
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="card card-large" ng-hide="busy || notifications.length">
<div class="card" ng-hide="busy || notifications.length">
<div class="row">
<div class="col-xs-12">
<h3 class="text-center" style="margin: 20px;">{{ 'notifications.nonePending' | tr }}</h3>
@@ -23,7 +23,7 @@
</div>
</div>
<div class="card card-large notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="row">
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
+2 -2
View File
@@ -395,8 +395,8 @@
<div class="grid-item-top">
<div class="row">
<div class="col-xs-3" style="min-width: 150px;">
<div class="settings-avatar" style="background-image: url('{{ user.avatarUrl }}');" ng-click="avatarChange.showChangeAvatar()">
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
<div class="settings-avatar" style="background-image: url('{{ user.avatarUrl }}');">
<div class="overlay" ng-click="avatarChange.showChangeAvatar()"></div>
</div>
</div>
<div class="col-xs-9">
+4 -8
View File
@@ -12,14 +12,11 @@
<div class="form-group">
<label class="control-label" style="display: block;" for="memoryLimit">
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit | prettyBinarySize:'' }}</b>
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit / 1024 / 1024 + 'MB' }}</b>
<button type="button" class="btn btn-xs btn-default pull-right" ng-click="serviceConfigure.resetToDefaults()">{{ 'services.configure.resetToDefaults' | tr }}</button>
</label>
<div style="padding: 0 10px;">
<input type="range" id="memoryLimit" ng-model="serviceConfigure.memoryLimit" step="134217728" min="{{ serviceConfigure.memoryTicks[0] }}" max="{{ serviceConfigure.memoryTicks[serviceConfigure.memoryTicks.length-1] }}" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option ng-repeat="limit in serviceConfigure.memoryTicks" value="{{ limit }}"></option>
</datalist>
<slider id="memoryLimit" ng-model="serviceConfigure.memoryLimit" step="134217728" tooltip="hide" ticks="serviceConfigure.memoryTicks" ticks-snap-bounds="67108864"></slider>
</div>
</div>
@@ -115,10 +112,9 @@
<span ng-show="service.config.memoryLimit">{{ service.config.memoryLimit | prettyBinarySize }}</span>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-disabled="service.status === 'disabled' || !service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<!-- restart is always clickable so that a user can rebuild mongodb in disabled state when using VMs where CPU flags can be dynamically changed -->
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-disabled="!service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="restartService(service.name)" uib-tooltip="{{ 'services.restartActionTooltip' | tr }}"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': service.status === 'starting' && !service.config.recoveryMode }"></i></button>
<a class="btn btn-xs btn-default" ng-href="{{ service.status === 'disabled' ? '' : ('/frontend/logs.html?id=' + service.name) }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}" ng-disabled="service.status === 'disabled'"><i class="fa fa-file-alt"></i></a>
<a class="btn btn-xs btn-default" ng-href="{{ '/frontend/logs.html?id=' + service.name }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
</td>
</tr>
<tr ng-show="hasRedisServices" ng-click="redisServicesExpanded = !redisServicesExpanded" class="hand">
+11 -18
View File
@@ -20,13 +20,12 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
if (error) return console.log('Error getting status of ' + serviceName + ':' + error.message);
var service = $scope.services.find(function (s) { return s.name === serviceName; });
if (!service) callback(new Error('no such service' + serviceName)); // cannot happen
if (!service) $scope.services[serviceName] = service;
service.status = result.status;
service.config = result.config;
service.memoryUsed = result.memoryUsed;
service.memoryPercent = result.memoryPercent;
service.defaultMemoryLimit = result.defaultMemoryLimit;
callback(null, service);
});
@@ -54,10 +53,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
return;
}
if (error) {
refresh(serviceName);
return Client.error(error);
}
if (error) return Client.error(error);
// show "busy" indicator for 3 seconds to show some ui activity
setTimeout(function () { waitForActive(serviceName); }, 3000);
@@ -79,22 +75,19 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.reset();
$scope.serviceConfigure.service = service;
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
$scope.serviceConfigure.recoveryMode = !!service.config.recoveryMode;
$scope.serviceConfigure.memoryTicks = [];
// we max system memory and current service memory for the case where the user configured the service on another server with more resources
var nearest256m = Math.ceil(Math.max($scope.memory.memory, service.config.memoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = service.defaultMemoryLimit;
for (var i = startTick; i <= nearest256m; i *= 2) {
$scope.serviceConfigure.memoryTicks.push(i);
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
$scope.serviceConfigure.memoryTicks = [];
var npow2 = Math.pow(2, Math.ceil(Math.log($scope.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
$scope.serviceConfigure.memoryTicks.push(i * 1024 * 1024);
}
// for firefox widget update
$timeout(function() {
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
}, 500);
$('#serviceConfigureModal').modal('show');
},
@@ -103,7 +96,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.error = null;
var data = {
memoryLimit: parseInt($scope.serviceConfigure.memoryLimit),
memoryLimit: $scope.serviceConfigure.memoryLimit,
recoveryMode: $scope.serviceConfigure.recoveryMode
};
@@ -126,7 +119,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
},
resetToDefaults: function () {
$scope.serviceConfigure.memoryLimit = $scope.serviceConfigure.service.defaultMemoryLimit;
$scope.serviceConfigure.memoryLimit = 256 * 1024 * 1024; // 256MB default
},
reset: function () {
+8 -17
View File
@@ -7,7 +7,7 @@
<h4 class="modal-title">{{ 'settings.updateDialog.title' | tr }} <b>{{config.update.box.version}}</b> </h4>
</div>
<div class="modal-body">
<div ng-hide="installedApps | canUpdate">
<div ng-hide="installedApps | readyToUpdate">
<p>{{ 'settings.updateDialog.blockingApps' | tr }}</p>
<ul>
<li ng-repeat="app in installedApps | inProgressApps">{{app.fqdn}}</li>
@@ -17,7 +17,7 @@
<br/>
</div>
<div ng-show="installedApps | canUpdate">
<div ng-show="installedApps | readyToUpdate">
<p class="text-danger" ng-show="config.update.box.unstable">{{ 'settings.updateDialog.unstableWarning' | tr }}</p>
<p>{{ 'settings.updateDialog.changes' | tr }}:</p>
<ul>
@@ -28,12 +28,12 @@
</div>
</div>
<div class="modal-footer">
<label ng-show="installedApps | canUpdate" class="checkbox-inline pull-left">
<label class="checkbox-inline pull-left">
<input type="checkbox" ng-model="update.skipBackup">{{ 'settings.updateDialog.skipBackupCheckbox' | tr }}
</label>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn" ng-show="installedApps | canUpdate" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
<button type="button" class="btn" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy" ng-show="(installedApps | readyToUpdate)"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
@@ -272,31 +272,22 @@
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<span ng-bind-html=" 'settings.updates.description' | tr "></span>
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ 'settings.updates.currentSchedule' | tr }} <b>{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</b></span>
<span ng-show="updateSchedule.currentPattern === 'never'" ng-bind-html=" 'settings.updates.autoUpdateDisabled' | tr "></span>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-4">
<div class="col-xs-6">
<span class="text-muted">{{ 'settings.updates.version' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<div class="col-xs-6 text-right">
v{{ config.version }} ({{ config.ubuntuVersion }})
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'settings.updates.schedule' | tr }}</span>
</div>
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</span>
<span ng-show="updateSchedule.currentPattern === 'never'">{{ 'settings.updates.disabled' | tr }}</span>
</div>
</div>
<div class="row">
<br/>
<div ng-if="update.busy" class="col-md-12" style="margin-bottom: 10px;">
+62 -10
View File
@@ -18,24 +18,76 @@
</div>
</div>
<div class="text-left" ng-if="troubleshoot">
<h3>Troubleshoot</h3>
<!-- <div class="text-left">
<h3>{{ 'support.ticket.title' | tr }}</h3>
</div>
<div class="card" ng-if="troubleshoot">
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="row" ng-hide="ready">
<h2 class="text-center"><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="row" ng-show="ready">
<div class="col-lg-12">
<p>Troubleshooting tools</p>
<div>
<button class="btn btn-default pull-right" ng-click="repairAll()"><i ng-show="repairAllBusy" class="fa fa-circle-notch fa-spin"></i> Repair All</button>
<button class="btn btn-default pull-right" ng-click="updateAll()"><i ng-show="updateAllBusy" class="fa fa-circle-notch fa-spin"></i> Update All</button>
<div ng-show="subscription && !subscription.emailVerified" style="margin-bottom: 30px;">
<p class="text-bold">
{{ 'support.ticket.emailNotVerified' | tr:{ email: subscription.email } }}
<br/>
<center>
<a ng-href="{{ config.consoleServerOrigin }}" target="_blank" class="btn btn-success">{{ 'support.ticket.emailVerifyAction' | tr }}</a>
</center>
</p>
</div>
<p class="text-small text-warning">{{ troubleshootingMessage }}</p>
<p>Use this form to open support tickets. You can also write directly to <a href="mailto:support@cloudron.io">support@cloudron.io.</p>
<ul>
<li><a href="https://docs.cloudron.io/apps/?support_view" target="_blank">Knowledge Base & App Docs</a></li>
<li><a href="https://docs.cloudron.io/custom-apps/tutorial/?support_view" target="_blank">Custom App Packaging & API</li>
<li><a href="https://forum.cloudron.io/" target="_blank">Forum</a></li>
</ul>
<form name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<label>{{ 'support.ticket.type' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required ng-disabled="!subscription.emailVerified">
<option value="app_error">{{ 'support.ticket.typeApp' | tr }}</option>
<option value="ticket">{{ 'support.ticket.typeBug' | tr }}</option>
<option value="billing">{{ 'support.ticket.typeBilling' | tr }}</option>
<option value="email_error">{{ 'support.ticket.typeEmail' | tr }}</option>
</select>
</div>
<div class="form-group" ng-show="feedback.type === 'app_error'">
<label>{{ 'support.ticket.selectApp' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'" ng-disabled="!subscription.emailVerified">
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
<label>{{ 'support.ticket.topic' | tr }}</label>
<input type="text" class="form-control" name="subject" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
<label>{{ 'support.ticket.report' | tr }}</label>
<textarea class="form-control" name="description" rows="3" placeholder="{{ 'support.ticket.reportPlaceholder' | tr }}" ng-model="feedback.description" ng-minlength="1" required ng-disabled="!subscription.emailVerified"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': feedbackForm.email.$invalid }">
<label>{{ 'support.ticket.email' | tr }}</label> <small>{{ 'support.ticket.emailInfo' | tr:{ email: subscription.email } }}</small>
<input type="email" class="form-control" name="email" placeholder="{{ 'support.ticket.emailPlaceholder' | tr }}" ng-model="feedback.altEmail" ng-required="feedback.type === 'email_error'" ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group">
<label class="control-label">
<input type="checkbox" ng-model="feedback.enableSshSupport" ng-disabled="!subscription.emailVerified"> {{ 'support.ticket.sshCheckbox' | tr }}
</label>
</div>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="!subscription.emailVerified || feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-notch fa-spin" ng-show="feedback.busy"></i> {{ 'support.ticket.submitAction' | tr }}</button>
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
<span ng-show="feedback.result" class="text-success text-bold">{{feedback.result.message}}</span>
</form>
</div>
</div>
</div>
</div>
</div> -->
<div class="text-left section-header">
<h3>{{ 'support.remoteSupport.title' | tr }}</h3>
+63 -48
View File
@@ -2,8 +2,6 @@
/* global angular:false */
/* global $:false */
/* global ISTATES */
/* global async */
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
@@ -11,62 +9,69 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.installedApps = Client.getInstalledApps();
// $scope.apps = Client.getInstalledApps();
// $scope.appsById = {};
// $scope.feedback = {
// error: null,
// result: null,
// busy: false,
// enableSshSupport: false,
// subject: '',
// type: 'app_error',
// description: '',
// appId: '',
// altEmail: ''
// };
$scope.toggleSshSupportError = '';
$scope.sshSupportEnabled = false;
// $scope.subscription = null;
$scope.troubleshoot = $location.search().troubleshoot;
// function resetFeedback() {
// $scope.feedback.enableSshSupport = false;
// $scope.feedback.subject = '';
// $scope.feedback.description = '';
// $scope.feedback.type = 'app_error';
// $scope.feedback.appId = '';
// $scope.feedback.altEmail = '';
$scope.updateAllBusy = false;
$scope.repairAllBusy = false;
$scope.troubleshootingMessage = '';
// $scope.feedbackForm.$setUntouched();
// $scope.feedbackForm.$setPristine();
// }
$scope.updateAll = function () {
$scope.updateAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0, unstable = 0;
// $scope.submitFeedback = function () {
// $scope.feedback.busy = true;
// $scope.feedback.result = null;
// $scope.feedback.error = null;
Client.checkForUpdates(function (error) {
if (error) Client.error(error);
// var data = {
// enableSshSupport: $scope.feedback.enableSshSupport,
// subject: $scope.feedback.subject,
// description: $scope.feedback.description,
// type: $scope.feedback.type,
// appId: $scope.feedback.appId,
// altEmail: $scope.feedback.altEmail
// };
async.eachSeries(Object.keys($scope.config.update), function (appId, iteratorDone) {
if ($scope.config.update[appId].unstable) { ++unstable; return iteratorDone(); }
// Client.createTicket(data, function (error, result) {
// if (error) {
// $scope.feedback.error = error.message;
// } else {
// $scope.feedback.result = result;
// resetFeedback();
// }
Client.updateApp(appId, $scope.config.update[appId].manifest, { skipBackup: false }, function (error) {
if (error) Client.error(error);
else ++count;
// $scope.feedback.busy = false;
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps updated. ${unstable} apps with unstable updates skipped.`;
$scope.updateAllBusy = false;
});
});
};
// // refresh state
// Client.getRemoteSupport(function (error, enabled) {
// if (error) return console.error(error);
$scope.repairAll = function () {
$scope.repairAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0;
Client.refreshInstalledApps(function () {
async.eachSeries($scope.installedApps, function (app, iteratorDone) {
if (app.installationState !== ISTATES.ERROR) return iteratorDone();
Client.repairApp(app.id, {}, function (error) {
if (error) Client.error(error);
else ++count;
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps repaired.`;
$scope.repairAllBusy = false;
});
});
};
// $scope.sshSupportEnabled = enabled;
// });
// });
// };
$scope.toggleSshSupport = function () {
$scope.toggleSshSupportError = '';
@@ -88,7 +93,17 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.sshSupportEnabled = enabled;
$scope.ready = true;
// Client.getSubscription(function (error, result) {
// if (error && error.statusCode === 402) return $scope.ready = true; // not yet registered
// if (error && error.statusCode === 412) return $scope.ready = true; // invalid appstore token
// if (error) return console.error(error);
// $scope.subscription = result;
// Client.getInstalledApps().forEach(function (app) { $scope.appsById[app.id] = app; });
$scope.ready = true;
// });
});
});
+1 -1
View File
@@ -100,7 +100,7 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
else content.label = content.app.label || content.app.fqdn;
} else if (content.type === 'volume') {
content.volume = $scope.volumesById[content.id];
content.label = content.volume ? content.volume.name : 'Removed volume';
content.label = content.volume.name;
}
// ensure a label for ui
+1 -1
View File
@@ -493,7 +493,7 @@
<div class="col-md-12">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.discoveryUrl' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#endpoints" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></td>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.discoveryUrl' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/.well-known/openid-configuration</td>
</tr>
</table>
+4 -7
View File
@@ -43,6 +43,9 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
},
submit: function () {
// prevent the current user from getting locked out
if ($scope.profileConfig.mandatory2FA && !$scope.userInfo.twoFactorAuthenticationEnabled) return Client.notify('', $translate.instant('users.settings.require2FAWarning'), true, 'error', '#/profile');
$scope.profileConfig.error = '';
$scope.profileConfig.busy = true;
$scope.profileConfig.success = false;
@@ -64,12 +67,6 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$timeout(function () {
$scope.profileConfig.busy = false;
// prevent the current user from getting locked out. if user ignores this, they have to use cloudron-support --admin-login
if ($scope.profileConfig.mandatory2FA && !$scope.userInfo.twoFactorAuthenticationEnabled) {
if ($scope.userInfo.source && $scope.config.external2FA) return; // no need for warning if 2fa is external
Client.notify('', $translate.instant('users.settings.require2FAWarning'), true, 'error', '#/profile');
}
}, 500);
});
}
@@ -131,7 +128,7 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
// fields
provider: 'noop',
autoCreate: true,
autoCreate: false,
url: '',
acceptSelfSignedCerts: false,
baseDn: '',
+55
View File
@@ -292,6 +292,48 @@
</div>
</div>
<!-- Modal user import -->
<div class="modal fade" id="userImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.userImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!userImport.done">
<div ng-show="!userImport.busy">
<p ng-bind-html=" 'users.userImportDialog.description' | tr:{ docsLink: 'https://docs.cloudron.io/user-management/#import-users' } "></p>
<input type="file" style="display: none;" id="userImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="userImport.openFileInput()">{{ 'users.userImportDialog.fileInput' | tr }}</button>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userImport.sendInvite" id="inputUserImportSendInvite"> {{ 'users.userImportDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<p class="text-danger" ng-show="userImport.error.file">{{ userImport.error.file }}</p>
<p class="text-info" ng-show="userImport.users.length">{{ 'users.userImportDialog.usersFound' | tr:{ count: userImport.users.length } }}</p>
</div>
<div ng-show="userImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ userImport.percent }}%"></div>
</div>
</div>
<div ng-show="userImport.done">
<p>{{ 'users.userImportDialog.success' | tr:{ count: userImport.success } }}</p>
<div ng-show="userImport.error.import.length">
<p class="text-danger">{{ 'users.userImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in userImport.error.import"><b>{{ tmp.user.email }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="userImport.import()" ng-show="!userImport.done" ng-disabled="userImport.busy || !userImport.users.length"><i class="fa fa-circle-notch fa-spin" ng-show="userImport.busy"></i> {{ 'users.userImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal password reset -->
<div class="modal fade" id="passwordResetModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -412,6 +454,19 @@
<input type="text" id="userSearchInput" class="form-control" style="max-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<multiselect ng-model="userStateFilter" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ userStateFilter }}" options="state.label for state in userStates" data-multiple="false"></multiselect>
<div style="flex-grow: 1;"></div>
<!-- import/export buttons are hidden until we figure what the exact use case is -->
<div class="btn-group" ng-hide="true">
<button class="btn btn-default" ng-click="userImport.show()" uib-tooltip="{{ 'users.userImport.tooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'users.userExport.tooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="userExport('csv')">{{ 'users.userExport.csv' | tr }}</a></li>
<li><a href="" ng-click="userExport('json')">{{ 'users.userExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
+165 -1
View File
@@ -67,6 +67,171 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return true;
};
$scope.userImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
users: [],
sendInvite: false,
reset: function () {
$scope.userImport.busy = false;
$scope.userImport.error = null;
$scope.userImport.users = [];
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.sendInvite = false;
},
handleFileChanged: function () {
$scope.userImport.reset();
var fileInput = document.getElementById('userImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.userImport.users = [];
var users = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.userImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 5) {
$scope.userImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 5' };
return;
}
users.push({
username: items[0].trim(),
email: items[1].trim(),
fallbackEmail: items[2].trim(),
displayName: items[3].trim(),
role: items[4].trim()
});
}
} else {
try {
users = JSON.parse(reader.result).map(function (user) {
return {
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role
};
});
} catch (e) {
console.error('Failed to parse users.', e);
$scope.userImport.error = { file: 'Imported file is not valid JSON:' + e.message };
}
}
$scope.userImport.users = users;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.userImport.reset();
// named so no duplactes
document.getElementById('userImportFileInput').addEventListener('change', $scope.userImport.handleFileChanged);
$('#userImportModal').modal('show');
},
openFileInput: function () {
$('#userImportFileInput').click();
},
import: function () {
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.error = { import: [] };
$scope.userImport.busy = true;
var processed = 0;
async.eachSeries($scope.userImport.users, function (user, callback) {
Client.addUser(user, function (error, userId) {
if (error) $scope.userImport.error.import.push({ error: error, user: user });
else ++$scope.userImport.success;
++processed;
$scope.userImport.percent = 100 * processed / $scope.userImport.users.length;
if (!error && $scope.userImport.sendInvite) {
console.log('sending', userId, user.email);
Client.sendInviteEmail(userId, user.email, function (error) {
if (error) console.error('Failed to send invite.', error);
});
}
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.userImport.busy = false;
$scope.userImport.done = true;
if ($scope.userImport.success) {
refreshCurrentPage();
refreshAllUsers();
}
});
}
};
// supported types are 'json' and 'csv'
$scope.userExport = function (type) {
Client.getAllUsers(function (error, result) {
if (error) {
Client.error('Failed to list users. Full error in the webinspector.');
return console.error('Failed to list users.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (user) {
return {
id: user.id,
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role,
active: user.active
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (user) {
return [ user.id, user.username, user.email, user.fallbackEmail, user.displayName, user.role, user.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = type === 'json' ? 'users.json' : 'users.csv';
document.body.appendChild(a);
a.click();
});
};
$scope.userRemove = {
busy: false,
error: null,
@@ -315,7 +480,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.groupAdd.error = {};
$scope.groupAdd.name = '';
$scope.groupAdd.selectedUsers = [];
$scope.groupAddForm.$setUntouched();
$scope.groupAddForm.$setPristine();
+4 -4
View File
@@ -95,7 +95,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
ext4Disk: null, // { path, type }
xfsDisk: null, // { path, type }
user: '',
seal: true,
seal: false,
port: 22,
privateKey: '',
@@ -112,7 +112,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.volumeAdd.ext4Disk = null;
$scope.volumeAdd.xfsDisk = null;
$scope.volumeAdd.user = '';
$scope.volumeAdd.seal = true;
$scope.volumeAdd.seal = false;
$scope.volumeAdd.port = 22;
$scope.volumeAdd.privateKey = '';
@@ -213,7 +213,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
username: '',
password: '',
user: '',
seal: true,
seal: false,
port: 22,
privateKey: '',
@@ -224,7 +224,7 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.volumeEdit.name = '';
$scope.volumeEdit.mountType = '';
$scope.volumeEdit.host = '';
$scope.volumeEdit.seal = true;
$scope.volumeEdit.seal = '';
$scope.volumeEdit.port = '';
$scope.volumeEdit.remoteDir = '';
$scope.volumeEdit.username = '';
-21
View File
@@ -1,21 +0,0 @@
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
js.configs.recommended,
{
files: ["**/*.js"],
languageOptions: {
globals: {
...globals.node,
},
ecmaVersion: 13,
sourceType: "commonjs"
},
rules: {
semi: "error",
"prefer-const": "error"
}
}
];
-22
View File
@@ -1,22 +0,0 @@
import globals from 'globals';
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
export default [
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
files: ["**/*.js"],
languageOptions: {
globals: {
...globals.browser,
},
ecmaVersion: 13,
sourceType: 'module'
},
rules: {
semi: "error",
"prefer-const": "error"
}
}
];
-7
View File
@@ -5,13 +5,6 @@
<link rel="icon" href="/api/v1/cloudron/avatar" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File Manager</title>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="app"></div>
-7
View File
@@ -5,13 +5,6 @@
<link rel="icon" href="/api/v1/cloudron/avatar" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Logs</title>
<style>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="app"></div>
+1066 -1479
View File
File diff suppressed because it is too large Load Diff
+17 -18
View File
@@ -1,34 +1,33 @@
{
"name": "frontend",
"name": "my-vue-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --base=/frontend/ --strictPort --port 4001",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.1.0",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@fontsource/noto-sans": "^5.0.19",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.6",
"marked": "^14.1.2",
"filesize": "^10.1.0",
"marked": "^12.0.1",
"moment": "^2.30.1",
"pankow": "^2.2.1",
"pankow-viewers": "^1.0.7",
"vue": "^3.5.6",
"vue-i18n": "^10.0.1",
"vue-router": "^4.4.5"
"pankow": "^1.2.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"superagent": "^8.1.2",
"vue": "^3.4.21",
"vue-i18n": "^9.10.1",
"vue-router": "^4.3.0",
"@xterm/xterm": "^5.4.0",
"@xterm/addon-attach": "^0.10.0",
"@xterm/addon-fit": "^0.9.0"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"@vitejs/plugin-vue": "^5.1.3",
"eslint": "^9.10.0",
"eslint-plugin-vue": "^9.28.0",
"vite": "^5.4.5"
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.5"
}
}
+4 -2
View File
@@ -1,12 +1,14 @@
<template>
<!-- router-view needs some fake node first for some unknown reason -->
<span></span>
<ConfirmDialog/>
<router-view></router-view>
</template>
<script>
import ConfirmDialog from 'primevue/confirmdialog';
export default {
components: { ConfirmDialog },
data() {
return {
};
+34 -47
View File
@@ -8,17 +8,18 @@
<span class="title">{{ name }}</span>
</template>
<template #right>
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button type="button" :label="$t('logs.clear')" icon="pi pi-eraser" @click="onClear()" style="margin-right: 5px" />
<a :href="downloadUrl" target="_blank"><Button :label="$t('logs.download')" icon="pi pi-download" /></a>
<a class="hide-phone" style="margin-left: 5px; margin-right: 5px;" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="type === 'app'"><Button type="button" severity="secondary" icon="pi pi-folder" :label="$t('filemanager.title')" /></a>
<a class="hide-phone" style="margin-right: 5px;" :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="type === 'app'"><Button severity="secondary" icon="pi pi-desktop" :label="$t('terminal.title')" /></a>
<Button class="hide-phone" type="button" :label="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="type === 'app'"/>
</template>
</TopBar>
</template>
<template #body>
<div ref="linesContainer"></div>
<div v-for="line of logLines" class="log-line">
<span class="time">{{ line.time || '[no timestamp]&nbsp;' }}</span> <span v-html="line.html"></span>
</div>
<div class="bottom-spacer"></div>
</template>
</MainLayout>
@@ -26,29 +27,37 @@
<script>
import { Button, TopBar, MainLayout } from 'pankow';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { TopBar, MainLayout } from 'pankow';
import LogsModel from '../models/LogsModel.js';
import AppModel from '../models/AppModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin ;
export default {
name: 'LogsViewer',
components: {
Button,
Dialog,
InputText,
MainLayout,
Menu,
ProgressSpinner,
TopBar
},
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
logsModel: null,
appModel: null,
busyRestart: false,
showRestart: false,
showFilemanager: false,
showTerminal: false,
id: '',
name: '',
type: '',
@@ -58,7 +67,7 @@ export default {
},
methods: {
onClear() {
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
this.logLines = [];
},
onDownload() {
this.logsModel.download();
@@ -112,17 +121,13 @@ export default {
return;
}
this.logsModel = LogsModel.create(API_ORIGIN, this.accessToken, this.type, this.id);
this.logsModel = LogsModel.create(this.apiOrigin, this.accessToken, this.type, this.id);
if (this.type === 'app') {
this.appModel = AppModel.create(API_ORIGIN, this.accessToken, this.id);
this.appModel = AppModel.create(this.apiOrigin, this.accessToken, this.id);
try {
const app = await this.appModel.get();
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
this.showFilemanager = !!app.manifest.addons.localstorage;
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
} catch (e) {
console.error(`Failed to get app info for ${this.id}:`, e);
}
@@ -132,34 +137,16 @@ export default {
this.downloadUrl = this.logsModel.getDownloadUrl();
const maxLines = 1000;
let lines = 0;
let newLogLines = [];
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
setInterval(() => {
newLogLines = newLogLines.slice(-maxLines)
for (let line of newLogLines) {
if (lines < maxLines) ++lines;
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp]&nbsp;' }</span> <span>${line.html}</span>`;
this.$refs.linesContainer.appendChild(logLine);
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
}
newLogLines = [];
}, 500);
this.logsModel.stream((time, html) => {
newLogLines.push({ time, html });
this.logLines.push({ time, html});
const tmp = document.getElementsByClassName('cloudron-layout-body')[0];
if (!tmp) return;
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
}, function (error) {
newLogLines.push({ time: error.time, html: error.html });
console.error('Failed to start log stream:', error);
})
}
};
@@ -176,7 +163,7 @@ body {
font-size: 20px;
}
.pankow-main-layout-body {
.cloudron-layout-body {
cursor: text;
}
@@ -186,7 +173,7 @@ body {
}
}
.pankow-top-bar {
.cloudron-top {
background-color: black;
color: white;
margin-bottom: 0 !important;
+82 -61
View File
@@ -1,12 +1,24 @@
<template>
<MainLayout :gap="false">
<template #dialogs>
<Dialog ref="fatalErrorDialog" modal title="Error">
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
<p>{{ fatalError }}</p>
</Dialog>
<InputDialog ref="inputDialog" />
<a id="fileDownloadLink" :href="downloadFileDownloadUrl" target="_blank"></a>
<Dialog v-model:visible="downloadFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('downloadFileDialogNameInput')">
<template #header>
<label class="dialog-header" for="downloadFileDialogNameInput">{{ $t('terminal.downloadAction') }}</label>
</template>
<template #default>
<form @submit="onDownloadFileDialogSubmit" @submit.prevent>
<p :v-show="downloadFileDialog.error">{{ downloadFileDialog.error }}</p>
<label for="downloadFileDialogNameInput">{{ $t('terminal.download.filePath') }}</label>
<InputText class="dialog-single-input" :class="{ 'p-invalid': downloadFileDialog.error }" id="downloadFileDialogNameInput" v-model="downloadFileDialog.name" :disabled="downloadFileDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('terminal.download.download')" :loading="downloadFileDialog.busy" :disabled="downloadFileDialog.busy || !downloadFileDialog.name"/>
</form>
<a id="fileDownloadLink" :href="downloadFileDialog.downloadUrl" target="_blank"></a>
</template>
</Dialog>
</template>
<template #header>
<TopBar class="navbar">
@@ -15,21 +27,22 @@
</template>
<template #right>
<!-- Scheduler/cron tasks -->
<Button success :menu="schedulerMenuModel" v-show="usesAddon('scheduler')" @click="onSchedulerMenu">{{ $t('terminal.scheduler') }}</Button>
<Button severity="success" :label="$t('terminal.scheduler')" v-show="usesAddon('scheduler')" icon="pi pi-angle-down" iconPos="right" @click="onSchedulerMenu" aria-haspopup="true" aria-controls="schedulerMenu" style="margin-right: 5px" />
<Menu ref="schedulerMenu" id="schedulerMenu" :model="schedulerMenuModel" :popup="true" />
<!-- addon actions -->
<Button success @click="terminalInject('mysql')" v-show="usesAddon('mysql')" :disabled="!connected">MySQL</Button>
<Button success @click="terminalInject('postgresql')" v-show="usesAddon('postgresql')" :disabled="!connected">Postgres</Button>
<Button success @click="terminalInject('mongodb')" v-show="usesAddon('mongodb')" :disabled="!connected">MongoDB</Button>
<Button success @click="terminalInject('redis')" v-show="usesAddon('redis')" :disabled="!connected">Redis</Button>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('mysql')" v-show="usesAddon('mysql')" :disabled="!connected" label="MySQL"/>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('postgresql')" v-show="usesAddon('postgresql')" :disabled="!connected" label="Postgres"/>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('mongodb')" v-show="usesAddon('mongodb')" :disabled="!connected" label="MongoDB"/>
<Button severity="success" style="margin-right: 5px;" @click="terminalInject('redis')" v-show="usesAddon('redis')" :disabled="!connected" label="Redis"/>
<!-- upload/download actions -->
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadTo', { path: '/app/data/' }) }}</Button>
<Button :disabled="!connected" @click="onDownload" icon="fa-solid fa-download">{{ $t('terminal.downloadAction') }}</Button>
<Button severity="primary" style="margin-right: 5px;" :disabled="!connected" @click="onUpload" icon="pi pi-upload" :label="$t('terminal.uploadToTmp')"/>
<Button severity="primary" style="margin-right: 5px;" :disabled="!connected" @click="onDownload" icon="pi pi-download" :label="$t('terminal.downloadAction')"/>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
<Button style="margin-right: 5px;" severity="secondary" type="button" v-tooltip.bottom="$t('filemanager.toolbar.restartApp')" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart"/>
<a style="margin-right: 5px;" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank"><Button severity="secondary" type="button" icon="pi pi-folder" v-tooltip.bottom="$t('filemanager.title')" /></a>
<a :href="'/frontend/logs.html?appId=' + id" target="_blank"><Button severity="secondary" icon="pi pi-align-left" v-tooltip.bottom="$t('logs.title')"/></a>
</template>
</TopBar>
</template>
@@ -48,7 +61,15 @@
<script>
import { fetcher, Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
import superagent from 'superagent';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { TopBar, MainLayout, FileUploader } from 'pankow';
import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm';
@@ -56,7 +77,6 @@ import { AttachAddon } from '@xterm/addon-attach';
import { FitAddon } from '@xterm/addon-fit';
import { create } from '../models/AppModel.js';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
@@ -66,20 +86,21 @@ export default {
Button,
Dialog,
FileUploader,
InputDialog,
InputText,
MainLayout,
Menu,
ProgressSpinner,
TopBar
},
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
appModel: null,
directoryModel: null,
fatalError: false,
busyRestart: false,
connected: false,
addons: {},
showFilemanager: false,
schedulerTasks: [],
manifestVersion: '',
schedulerMenuModel: [],
@@ -87,57 +108,61 @@ export default {
name: '',
socket: null,
terminal: null,
downloadFileDownloadUrl: ''
downloadFileDialog: {
busy: false,
name: '',
error: '',
downloadUrl: '',
visible: false,
}
};
},
methods: {
onFatalError(errorMessage) {
this.fatalError = errorMessage;
this.$refs.fatalErrorDialog.open();
},
// generic dialog focus handler TODO move to pankow and reuse in filemanger
onDialogShow(focusElementId) {
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
},
async onDownload() {
this.downloadFileDownloadUrl = '';
const downloadFileName = await this.$refs.inputDialog.prompt({
message: this.$t('terminal.downloadAction'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('terminal.download.download'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!downloadFileName) return;
onDownload() {
this.downloadFileDialog.busy = false;
this.downloadFileDialog.name = '';
this.downloadFileDialog.error = '';
this.downloadFileDialog.downloadUrl = '';
this.downloadFileDialog.visible = true;
},
async onDownloadFileDialogSubmit() {
this.downloadFileDialog.busy = true;
try {
await fetcher.head(`${API_ORIGIN}/api/v1/apps/${this.id}/download`, {
file: downloadFileName,
const result = await superagent.head(`${this.apiOrigin}/api/v1/apps/${this.id}/download`).query({
file: this.downloadFileDialog.name,
access_token: this.accessToken
});
} catch (error) {
if (error.status === 404) console.error('The requested file does not exist.');
this.downloadFileDialog.busy = false;
if (error.status === 404) this.downloadFileDialog.error = 'The requested file does not exist.';
else console.error('Failed', error);
return;
}
this.downloadFileDownloadUrl = `${API_ORIGIN}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
this.downloadFileDialog.downloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(this.downloadFileDialog.name)}&access_token=${this.accessToken}`;
// we have to click the link to make the browser do the download
// don't know how to prevent the browsers
this.$nextTick(() => {
document.getElementById('fileDownloadLink').click();
this.downloadFileDialog.visible = false;
});
},
onUpload() {
this.$refs.fileUploader.onUploadFile('/');
this.$refs.fileUploader.onUploadFile('/tmp');
},
async uploadHandler(targetDir, file, progressHandler) {
await this.directoryModel.upload(targetDir, file, progressHandler);
await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/upload`)
.query({ access_token: this.accessToken, file: `${targetDir}/${file.name}` })
.attach('file', file)
.on('progress', progressHandler);
},
usesAddon(addon) {
return !!Object.keys(this.addons).find(function (a) { return a === addon; });
@@ -167,8 +192,6 @@ export default {
} else if (addon === 'redis') {
if (this.manifestVersion === 1) {
cmd = 'redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDIS_PASSWORD}"';
} else if (this.addons['redis'].noPassword) {
cmd = 'redis-cli -h "${CLOUDRON_REDIS_HOST}" -p "${CLOUDRON_REDIS_PORT}"';
} else {
cmd = 'redis-cli -h "${CLOUDRON_REDIS_HOST}" -p "${CLOUDRON_REDIS_PORT}" -a "${CLOUDRON_REDIS_PASSWORD}" --no-auth-warning';
}
@@ -192,11 +215,11 @@ export default {
this.busyRestart = false;
},
async connect(retry = false) {
document.getElementsByClassName('pankow-main-layout-body')[0].innerHTML = '';
document.getElementsByClassName('cloudron-layout-body')[0].innerHTML = '';
let execId;
try {
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.id}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: this.accessToken });
const result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/exec`).query({ access_token: this.accessToken }).send({ cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' });
execId = result.body.id;
} catch (error) {
console.error('Cannot create socket.', error);
@@ -204,7 +227,7 @@ export default {
}
this.terminal = new Terminal();
this.terminal.open(document.getElementsByClassName('pankow-main-layout-body')[0]);
this.terminal.open(document.getElementsByClassName('cloudron-layout-body')[0]);
if (retry) this.terminal.writeln('Reconnecting...');
else this.terminal.writeln('Connecting...');
@@ -219,11 +242,16 @@ export default {
});
// websocket cannot use relative urls
const url = `${API_ORIGIN.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
const url = `${this.apiOrigin.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
this.socket = new WebSocket(url);
this.terminal.loadAddon(new AttachAddon(this.socket));
// Let the browser handle paste
// this.terminal.attachCustomKeyEventHandler((event) => {
// if (event.key === 'V' && (event.ctrlKey || event.metaKey)) return false;
// });
this.socket.addEventListener('open', (event) => {
this.connected = true;
});
@@ -260,35 +288,28 @@ export default {
this.id = id;
this.name = id;
this.appModel = create(API_ORIGIN, this.accessToken, this.id);
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, `apps/${id}`);
this.appModel = create(this.apiOrigin, this.accessToken, this.id);
try {
const app = await this.appModel.get();
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
this.addons = app.manifest.addons;
this.manifestVersion = app.manifest.manifestVersion;
this.showFilemanager = !!app.manifest.addons.localstorage;
this.schedulerMenuModel = !app.manifest.addons.scheduler ? [] : Object.keys(app.manifest.addons.scheduler).map((k) => {
return {
label: k,
action: () => this.terminalInject('scheduler', app.manifest.addons.scheduler[k].command)
label: () => k,
command: () => this.terminalInject('scheduler', app.manifest.addons.scheduler[k].command)
};
});
} catch (e) {
console.error(`Failed to get app info for ${this.id}:`, e);
return this.onFatalError(`Unknown app ${this.id}. Cannot continue.`);
this.fatalError = `Unknown app ${this.id}. Cannot continue.`;
return;
}
window.document.title = `Terminal - ${this.name}`;
window.addEventListener('beforeunload', function (e) {
e.stopPropagation();
e.preventDefault();
return false;
}, true );
window.addEventListener('keydown', (event) => {
if (event.key === 'C' && (event.ctrlKey || event.metaKey)) { // ctrl shift c
event.preventDefault();
@@ -321,7 +342,7 @@ body {
font-size: 20px;
}
.pankow-top-bar {
.cloudron-top {
background-color: black;
color: white;
margin-bottom: 0 !important;
+50 -3
View File
@@ -1,12 +1,22 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import 'primevue/resources/themes/saga-blue/theme.css';
// import 'primevue/resources/themes/arya-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import Tooltip from 'primevue/tooltip';
import { createRouter, createWebHashHistory } from 'vue-router';
import i18n from './i18n.js';
import FileManager from './FileManager.vue';
import Home from './views/Home.vue';
import Viewer from './views/Viewer.vue';
@@ -23,11 +33,48 @@ const router = createRouter({
routes,
});
(async function init() {
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
const app = createApp(FileManager);
app.use(await i18n());
app.use(i18n);
app.use(router);
app.use(PrimeVue, { ripple: true });
app.directive('tooltip', Tooltip);
app.use(ConfirmationService);
app.mount('#app');
})();
-51
View File
@@ -1,51 +0,0 @@
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
import { createI18n } from 'vue-i18n';
import { fetcher } from 'pankow';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: function (keys, key) {
const message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// if not found return null to fallback to resolving for english
if (message === null) return null;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
async function loadLanguage(lang) {
try {
const result = await fetcher.get(`${API_ORIGIN}/translation/${lang}.json`);
translations[lang] = result.body;
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
async function main() {
// load at least fallback english
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
return i18n;
}
export default main;
+48 -3
View File
@@ -1,16 +1,61 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import i18n from './i18n.js';
import 'primevue/resources/themes/saga-blue/theme.css';
// import 'primevue/resources/themes/arya-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import LogsViewer from './components/LogsViewer.vue';
(async function init() {
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
const app = createApp(LogsViewer);
app.use(await i18n());
app.use(i18n);
app.use(PrimeVue, { ripple: true });
app.use(ConfirmationService);
app.mount('#app');
})();
+9 -9
View File
@@ -1,6 +1,6 @@
import superagent from 'superagent';
import { ISTATES } from '../constants.js';
import { fetcher } from 'pankow';
import { sleep } from 'pankow/utils';
export function create(origin, accessToken, id) {
@@ -9,13 +9,13 @@ export function create(origin, accessToken, id) {
async get() {
let error, result;
try {
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid app ${id}`, error || result.status);
if (error || result.statusCode !== 200) {
console.error(`Invalid app ${id}`, error || result.statusCode);
this.fatalError = `Invalid app ${id}`;
return;
}
@@ -25,25 +25,25 @@ export function create(origin, accessToken, id) {
async restart() {
let error, result;
try {
result = await fetcher.post(`${origin}/api/v1/apps/${id}/restart`, null, { access_token: accessToken });
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.status);
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
return;
}
while(true) {
let result;
try {
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
+35 -79
View File
@@ -1,5 +1,5 @@
import { fetcher } from 'pankow';
import superagent from 'superagent';
import { sanitize } from 'pankow/utils';
const BASE_URL = import.meta.env.BASE_URL || '/';
@@ -35,15 +35,15 @@ export function createDirectoryModel(origin, accessToken, api) {
async listFiles(path) {
let error, result;
try {
result = await fetcher.get(`${origin}/api/v1/${api}/files/${path}`, { access_token: accessToken });
result = await superagent.get(`${origin}/api/v1/${api}/files/${path}`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
if (error || result.statusCode !== 200) {
if (error.status === 404) return [];
console.error('Failed to list files', error || result.status);
console.error('Failed to list files', error || result.statusCode);
return [];
}
@@ -59,8 +59,6 @@ export function createDirectoryModel(origin, accessToken, api) {
// if we have an image, attach previewUrl
if (item.mimeType.indexOf('image/') === 0) {
item.previewUrl = `${origin}/api/v1/${api}/files/${encodeURIComponent(path + '/' + item.fileName)}?access_token=${accessToken}`;
} else {
item.previewUrl = '';
}
item.owner = item.uid;
@@ -69,100 +67,58 @@ export function createDirectoryModel(origin, accessToken, api) {
return result.body.entries;
},
upload(targetDir, file, progressHandler) {
async upload(targetDir, file, progressHandler) {
// file may contain a file name or a file path + file name
const relativefilePath = (file.webkitRelativePath ? file.webkitRelativePath : file.name);
const xhr = new XMLHttpRequest();
const req = new Promise(function (resolve, reject) {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject({
status: xhr.status,
statusText: xhr.statusText
});
}
});
xhr.addEventListener('error', () => {
reject({
status: xhr.status,
statusText: xhr.statusText
});
});
xhr.upload.addEventListener('progress', (event) => {
if (event.loaded) progressHandler({ direction: 'upload', loaded: event.loaded});
});
xhr.open('POST', `${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}?access_token=${accessToken}&overwrite=true`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.send(file);
});
// attach for upstream xhr.abort()
req.xhr = xhr;
return req;
await superagent.post(`${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}`)
.query({ access_token: accessToken })
.attach('file', file)
.on('progress', progressHandler);
},
async newFile(filePath) {
await this.save(filePath, '');
async newFile(folderPath, fileName) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.attach('file', new File([], fileName));
},
async newFolder(folderPath) {
await fetcher.post(`${origin}/api/v1/${api}/files/${folderPath}`, { access_token: accessToken, directory: true });
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.send({ directory: true });
},
async remove(filePath) {
await fetcher.del(`${origin}/api/v1/${api}/files/${filePath}`, { access_token: accessToken });
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken });
},
async rename(fromFilePath, toFilePath, overwrite = false) {
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'rename', newFilePath: sanitize(toFilePath), overwrite }, { access_token: accessToken });
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
.query({ access_token: accessToken });
},
async copy(fromFilePath, toFilePath) {
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'copy', newFilePath: sanitize(toFilePath) }, { access_token: accessToken });
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'copy', newFilePath: sanitize(toFilePath) })
.query({ access_token: accessToken });
},
async chown(filePath, uid) {
await fetcher.put(`${origin}/api/v1/${api}/files/${filePath}`, { action: 'chown', uid: uid, recursive: true }, { access_token: accessToken });
await superagent.put(`${origin}/api/v1/${api}/files/${filePath}`)
.send({ action: 'chown', uid: uid, recursive: true })
.query({ access_token: accessToken });
},
async extract(path) {
await fetcher.put(`${origin}/api/v1/${api}/files/${path}`, { action: 'extract' }, { access_token: accessToken });
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
.send({ action: 'extract' })
.query({ access_token: accessToken });
},
async download(path) {
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
},
async save(filePath, content) {
const file = new File([content], 'file');
const req = new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject({
status: xhr.status,
statusText: xhr.statusText
});
}
});
xhr.addEventListener('error', () => {
reject({
status: xhr.status,
statusText: xhr.statusText
});
});
xhr.open('POST', `${origin}/api/v1/${api}/files/${filePath}?access_token=${accessToken}&overwrite=true`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.setRequestHeader('Content-Length', file.size);
xhr.send(file);
});
await req;
await superagent.post(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken })
.attach('file', file)
.field('overwrite', 'true');
},
async getFile(path) {
let result;
@@ -178,7 +134,7 @@ export function createDirectoryModel(origin, accessToken, api) {
},
async paste(targetDir, action, files) {
// this will not overwrite but tries to find a new unique name to past to
for (const f in files) {
for (let f in files) {
let done = false;
let targetPath = targetDir + '/' + files[f].name;
while (!done) {
+49 -12
View File
@@ -1,6 +1,9 @@
import moment from 'moment';
import superagent from 'superagent';
import { ansiToHtml } from 'anser';
import { ISTATES } from '../constants.js';
import { sleep } from 'pankow/utils';
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
const entityMap = {
@@ -55,17 +58,7 @@ export function create(origin, accessToken, type, id) {
name: 'LogsModel',
stream(lineHandler, errorHandler) {
eventSource = new EventSource(`${origin}${streamApi}?lines=${INITIAL_STREAM_LINES}&access_token=${accessToken}`);
eventSource._lastMessage = null;
eventSource.onerror = function ( /* uselessError */) {
if (eventSource.readyState === EventSource.CLOSED) {
// eventSource does not give us the HTTP error code. We have to resort to message count check and guess the reason
const msg = eventSource._lastMessage === null ? `Logs unavailable. Maybe the logs were logrotated.` : `Connection closed.`;
const e = new Error(msg);
e.time = moment().format('MMM DD HH:mm:ss');
e.html = ansiToHtml(e.message);
errorHandler(e);
}
};
eventSource.onerror = errorHandler;
// eventSource.onopen = function () { console.log('stream is open'); };
eventSource.onmessage = function (message) {
var data;
@@ -79,12 +72,56 @@ export function create(origin, accessToken, type, id) {
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
eventSource._lastMessage = { time, html };
lineHandler(time, html);
};
},
getDownloadUrl() {
return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`;
},
// TODO maybe move this into AppsModel.js
async getApp() {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid app ${id}`, error || result.statusCode);
this.fatalError = `Invalid app ${id}`;
return;
}
return result.body;
},
async restartApp() {
if (type !== 'app') return;
let error, result;
try {
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
return;
}
while(true) {
let result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
}
};
}
+33 -1
View File
@@ -6,7 +6,19 @@ html, body {
padding: 0;
margin: 0;
background-color: #e5e5e5;
color: var(--pankow-text-color);
}
h1 {
font-weight: 300 !important;
}
a {
color: #2196f3;
text-decoration: none;
}
a:hover, a:focus {
color: #0a6ebd;
}
.shadow {
@@ -16,3 +28,23 @@ html, body {
#app {
height: 100%;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(1.1);
}
.p-button {
font-family: Noto Sans !important;
}
.p-button.p-button-success, .p-buttonset.p-button-success > .p-button, .p-splitbutton.p-button-success > .p-button {
background: #27ce65 !important;
border: 1px solid #27ce65 !important;
}
+51 -3
View File
@@ -1,16 +1,64 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import i18n from './i18n.js';
import 'primevue/resources/themes/saga-blue/theme.css';
// import 'primevue/resources/themes/arya-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import Tooltip from 'primevue/tooltip';
import Terminal from './components/Terminal.vue';
(async function init() {
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
const app = createApp(Terminal);
app.use(await i18n());
app.use(i18n);
app.use(PrimeVue, { ripple: true });
app.use(ConfirmationService);
app.directive('tooltip', Tooltip);
app.mount('#app');
})();
+310 -271
View File
@@ -1,45 +1,67 @@
<template>
<MainLayout>
<template #dialogs>
<Dialog ref="fatalErrorDialog" modal title="Error">
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
<p>{{ fatalError }}</p>
</Dialog>
<Dialog ref="extractInProgressDialog" modal :title="$t('filemanager.extractionInProgress')">
<Dialog v-model:visible="extractInProgress" modal :header="$t('filemanager.extractionInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog ref="pasteInProgressDialog" modal :title="$t('filemanager.pasteInProgress')">
<Dialog v-model:visible="pasteInProgress" modal :header="$t('filemanager.pasteInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog ref="deleteInProgressDialog" modal :title="$t('filemanager.deleteInProgress')">
<Dialog v-model:visible="deleteInProgress" modal :header="$t('filemanager.deleteInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<InputDialog ref="inputDialog" />
<!-- have to use v-model instead of : bind - https://github.com/primefaces/primevue/issues/815 -->
<Dialog v-model:visible="newFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFileDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFileDialogNameInput">{{ $t('filemanager.newFileDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFileDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFileDialogNameInput" v-model="newFileDialog.name" :disabled="newFileDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFileDialog.busy" :disabled="newFileDialog.busy || !newFileDialog.name"/>
</form>
</template>
</Dialog>
<Dialog v-model:visible="newFolderDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFolderDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFolderDialogNameInput">{{ $t('filemanager.newDirectoryDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFolderDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFolderDialogNameInput" v-model="newFolderDialog.name" :disabled="newFolderDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFolderDialog.busy" :disabled="newFolderDialog.busy || !newFolderDialog.name"/>
</form>
</template>
</Dialog>
</template>
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
<Button icon="pi pi-refresh" @click="onRefresh()" text :loading="busyRefresh" style="margin-right: 5px;"/>
<PathBreadcrumbs :path="cwd" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
<Button icon="fa-solid fa-plus" @click="onCreateMenu">{{ $t('filemanager.toolbar.new') }}</Button>
<Menu ref="createMenu" :model="createMenuModel"/>
<Button icon="fa-solid fa-upload" @click="onUploadMenu">{{ $t('filemanager.toolbar.upload') }}</Button>
<Menu ref="uploadMenu" :model="uploadMenuModel"/>
<Button type="button" :label="$t('filemanager.toolbar.new')" icon="pi pi-plus" @click="onCreateMenu" aria-haspopup="true" aria-controls="create_menu" style="margin-right: 5px" />
<Menu ref="createMenu" id="create_menu" :model="createMenuModel" :popup="true" />
<Button type="button" :label="$t('filemanager.toolbar.upload')" icon="pi pi-upload" @click="onUploadMenu" aria-haspopup="true" aria-controls="upload_menu" style="margin-right: 5px" />
<Menu ref="uploadMenu" id="upload_menu" :model="uploadMenuModel" :popup="true" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
<Button style="margin-left: 20px; margin-right: 5px;" type="button" v-tooltip.bottom="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="resourceType === 'app'"/>
<a style="margin-right: 5px;" :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'"><Button severity="secondary" icon="pi pi-desktop" v-tooltip.bottom="$t('terminal.title')" /></a>
<a :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'"><Button severity="secondary" icon="pi pi-align-left" v-tooltip.bottom="$t('logs.title')" /></a>
</template>
</TopBar>
</template>
@@ -47,8 +69,6 @@
<div class="main-view">
<div class="main-view-col">
<DirectoryView
class="directory-view"
:busy="busy"
:show-owner="true"
:show-size="true"
:show-modified="true"
@@ -57,6 +77,8 @@
:delete-handler="deleteHandler"
:rename-handler="renameHandler"
:change-owner-handler="changeOwnerHandler"
:copy-handler="copyHandler"
:cut-handler="cutHandler"
:paste-handler="pasteHandler"
:download-handler="downloadHandler"
:extract-handler="extractHandler"
@@ -66,13 +88,14 @@
:upload-folder-handler="onUploadFolder"
:drop-handler="onDrop"
:items="items"
:clipboard="clipboard"
:owners-model="ownersModel"
:fallback-icon="fallbackIcon"
:tr="$t"
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<div class="side-bar-title">
<div class="title-bar">
<a v-show="appLink" :href="appLink" target="_blank">{{ title }}</a>
<span v-show="!appLink">{{ title }}</span>
</div>
@@ -84,7 +107,6 @@
<FileUploader
ref="fileUploader"
:upload-handler="uploadHandler"
:cancel-handler="onCancelUpload"
@finished="onUploadFinished"
:tr="$t"
/>
@@ -97,10 +119,18 @@
<script>
import superagent from 'superagent';
import { marked } from 'marked';
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
import Icon from 'pankow/components/Icon.vue';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { useConfirm } from 'primevue/useconfirm';
import { DirectoryView, TopBar, PathBreadcrumbs, BottomBar, MainLayout, FileUploader } from 'pankow';
import { sanitize, sleep } from 'pankow/utils';
import { ISTATES } from '../constants.js';
@@ -108,7 +138,7 @@ import { ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
const BASE_URL = import.meta.env.BASE_URL || '/';
const beforeUnloadListener = (event) => {
@@ -124,204 +154,110 @@ export default {
Dialog,
DirectoryView,
FileUploader,
InputDialog,
InputText,
MainLayout,
Menu,
Breadcrumb,
PathBreadcrumbs,
PreviewPanel,
Spinner,
TopBar,
Icon
ProgressSpinner,
TopBar
},
data() {
return {
busy: true,
fallbackIcon: `${BASE_URL}mime-types/none.svg`,
cwd: '/',
busyRefresh: false,
busyRestart: false,
fatalError: false,
extractInProgress: false,
pasteInProgress: false,
deleteInProgress: false,
footerContent: '',
activeItem: null,
activeDirectoryItem: {},
items: [],
selectedItems: [],
clipboard: {
action: '', // copy or cut
files: []
},
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
title: 'Cloudron',
appLink: '',
resourceType: '',
resourceId: '',
visible: true,
uploadRequest: null,
breadcrumbHomeItem: {
label: '/app/data/',
action: () => {
this.cwd = '/';
}
newFileDialog: {
visible: false,
busy: false,
name: ''
},
newFolderDialog: {
visible: false,
busy: false,
name: ''
},
ownersModel: [],
// contextMenuModel will have activeItem attached if any command() is called
createMenuModel: [{
label: this.$t('filemanager.toolbar.newFile'),
icon: 'fa-solid fa-file-circle-plus',
action: this.onNewFile
label: () => this.$t('filemanager.toolbar.newFile'),
icon: 'pi pi-file',
command: this.onNewFile
}, {
label: this.$t('filemanager.toolbar.newFolder'),
icon: 'fa-solid fa-folder-plus',
action: this.onNewFolder
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onNewFolder
}],
uploadMenuModel: [{
label: this.$t('filemanager.toolbar.uploadFile'),
icon: 'fa-solid fa-file-arrow-up',
action: this.onUploadFile
label: () => this.$t('filemanager.toolbar.uploadFile'),
icon: 'pi pi-file',
command: this.onUploadFile
}, {
label: this.$t('filemanager.toolbar.newFolder'),
icon: 'fa-regular fa-folder-open',
action: this.onUploadFolder
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onUploadFolder
}]
};
},
computed: {
breadcrumbItems() {
const parts = this.cwd.split('/').filter((p) => !!p.trim())
const crumbs = [];
parts.forEach((p, i) => {
crumbs.push({
label: p,
action: () => {
this.cwd = '/' + parts.slice(0, i+1).join('/');
}
});
});
return crumbs;
}
},
watch: {
cwd(newCwd, oldCwd) {
if (this.resourceType && this.resourceId) this.$router.push(`/home/${this.resourceType}/${this.resourceId}${this.cwd}`);
this.loadCwd();
}
},
async mounted() {
this.busy = true;
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'app') {
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} else if (type === 'volume') {
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/volumes/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.title = result.body.name;
} else {
return this.onFatalError(`Unsupported type ${type}`);
}
try {
const result = await fetcher.get(`${API_ORIGIN}/api/v1/dashboard/config`, { access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
});
},
methods: {
onFatalError(errorMessage) {
this.fatalError = errorMessage;
this.$refs.fatalErrorDialog.open();
onCreateMenu(event) {
this.$refs.createMenu.toggle(event);
},
onCreateMenu(event, elem = null) {
this.$refs.createMenu.open(event, elem);
},
onUploadMenu(event, elem = null) {
this.$refs.uploadMenu.open(event, elem);
},
onCancelUpload() {
if (!this.uploadRequest || !this.uploadRequest.xhr) return;
this.uploadRequest.xhr.abort();
onUploadMenu(event) {
this.$refs.uploadMenu.toggle(event);
},
// generic dialog focus handler
onDialogShow(focusElementId) {
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
},
async onNewFile() {
const newFileName = await this.$refs.inputDialog.prompt({
message: this.$t('filemanager.newFileDialog.title'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('filemanager.newFileDialog.create'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!newFileName) return;
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName));
await this.loadCwd();
onNewFile() {
this.newFileDialog.busy = false;
this.newFileDialog.name = '';
this.newFileDialog.visible = true;
},
async onNewFolder() {
const newFolderName = await this.$refs.inputDialog.prompt({
message: this.$t('filemanager.newDirectoryDialog.title'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('filemanager.newFileDialog.create'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!newFolderName) return;
await this.directoryModel.newFolder(this.directoryModel.buildFilePath(this.cwd, newFolderName));
async onNewFileDialogSubmit() {
this.newFileDialog.busy = true;
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, this.newFileDialog.name), this.newFileDialog.name);
await this.loadCwd();
this.newFileDialog.visible = false;
},
onNewFolder() {
this.newFolderDialog.busy = false;
this.newFolderDialog.name = '';
this.newFolderDialog.visible = true;
},
async onNewFolderDialogSubmit() {
this.newFolderDialog.busy = true;
await this.directoryModel.newFolder(this.directoryModel.buildFilePath(this.cwd, this.newFolderDialog.name));
await this.loadCwd();
this.newFolderDialog.visible = false;
},
onUploadFile() {
this.$refs.fileUploader.onUploadFile(this.cwd);
@@ -353,62 +289,48 @@ export default {
async onDrop(targetFolder, dataTransfer, files) {
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
// if dataTransfer is set, we have a file/folder drop from outside
if (dataTransfer) {
async function getFile(entry) {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
let folderItem;
try {
folderItem = dataTransfer.items[0].webkitGetAsEntry();
if (folderItem.isFile) return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
} catch (e) {
return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
}
async function readEntries(dirReader) {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
}
const fileList = [];
async function traverseFileTree(item) {
// if we got here we have a folder drop and a modern browser
// now traverse the folder tree and create a file list
var that = this;
function traverseFileTree(item, path) {
if (item.isFile) {
fileList.push(await getFile(item));
item.file(function (file) {
that.$refs.fileUploader.addFiles([file], sanitize(`${that.cwd}/${targetFolder}`), false);
});
} else if (item.isDirectory) {
// Get folder contents
const dirReader = item.createReader();
const entries = await readEntries(dirReader);
for (let i in entries) {
await traverseFileTree(entries[i], item.name);
}
} else {
console.log('Skipping uknown file type', item);
var dirReader = item.createReader();
dirReader.readEntries(function (entries) {
for (let i in entries) {
traverseFileTree(entries[i], item.name);
}
});
}
}
// collect all files to upload
for (const item of dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (entry.isFile) {
fileList.push(await getFile(entry));
} else if (entry.isDirectory) {
await traverseFileTree(entry, sanitize(`${this.cwd}/${targetFolder}`));
}
}
this.$refs.fileUploader.addFiles(fileList, sanitize(`${this.cwd}/${targetFolder}`));
traverseFileTree(folderItem, '');
} else {
if (!files.length) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.open();
this.pasteInProgress = true;
// check ctrl for cut/copy
await this.directoryModel.paste(fullTargetFolder, 'cut', files);
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.close();
this.pasteInProgress = false;
}
},
onItemActivated(item) {
@@ -421,31 +343,39 @@ export default {
async deleteHandler(files) {
if (!files) return;
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.removeDialog.reallyDelete'),
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no'),
modal: false
});
if (!confirmed) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.deleteInProgressDialog.open();
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
function start_and_end(str) {
if (str.length > 100) {
return str.substr(0, 45) + ' ... ' + str.substr(str.length-45, str.length);
}
return str;
}
await this.loadCwd();
this.$confirm.require({
header: this.$t('filemanager.removeDialog.reallyDelete'),
message: start_and_end(files.map((f) => f.name).join(', ')),
icon: '',
acceptClass: 'p-button-danger',
accept: async () => {
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = true;
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
}
}
await this.loadCwd();
this.$confirm.close();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = false;
}
});
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.deleteInProgressDialog.close();
},
async renameHandler(file, newName) {
if (file.name === newName) return;
@@ -455,17 +385,16 @@ export default {
await this.loadCwd();
} catch (e) {
if (e.status === 409) {
const confirmed = await this.$refs.inputDialog.confirm({
this.$confirm.require({
message: this.$t('filemanager.renameDialog.reallyOverwrite'),
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no')
icon: '',
acceptClass: 'p-button-danger',
accept: async () => {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
await this.loadCwd();
this.$confirm.close();
}
});
if (!confirmed) return;
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
await this.loadCwd();
}
else console.error(`Failed to rename ${file} to ${newName}`, e);
}
@@ -479,41 +408,48 @@ export default {
await this.loadCwd();
},
async pasteHandler(action, files, target) {
if (!files || !files.length) return;
async copyHandler(files) {
if (!files) return;
this.clipboard = {
action: 'copy',
files
};
},
async cutHandler(files) {
if (!files) return;
this.clipboard = {
action: 'cut',
files
};
},
async pasteHandler(target) {
if (!this.clipboard.files || !this.clipboard.files.length) return;
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.open();
this.pasteInProgress = true;
await this.directoryModel.paste(targetPath, action, files);
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
this.clipboard = {};
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.close();
this.pasteInProgress = false;
},
async downloadHandler(file) {
await this.directoryModel.download(this.directoryModel.buildFilePath(this.cwd, file.name));
},
async extractHandler(file) {
this.$refs.extractInProgressDialog.open();
this.extractInProgress = true;
await this.directoryModel.extract(this.directoryModel.buildFilePath(this.cwd, file.name));
await this.loadCwd();
this.$refs.extractInProgressDialog.close();
this.extractInProgress = false;
},
async uploadHandler(targetDir, file, progressHandler) {
this.uploadRequest = this.directoryModel.upload(targetDir, file, progressHandler);
try {
await this.uploadRequest;
} catch (e) {
console.log('Upload cancelled.', e);
}
this.uploadRequest = null;
await this.directoryModel.upload(targetDir, file, progressHandler);
await this.loadCwd();
},
async loadCwd() {
@@ -530,8 +466,6 @@ export default {
mimeType: 'inode/directory',
icon: `${BASE_URL}mime-types/inode-directory.svg`
};
this.busy = false;
},
async onRestartApp() {
if (this.resourceType !== 'app') return;
@@ -540,31 +474,108 @@ export default {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.resourceId}/restart`, null, { access_token: this.accessToken });
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.status);
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
return;
}
while(true) {
let result;
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${this.resourceId}`, { access_token: this.accessToken });
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
console.error('Failed to fetch app status.', e);
error = e;
}
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
this.busyRestart = false;
}
},
async mounted() {
useConfirm();
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'app') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} else if (type === 'volume') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.title = result.body.name;
} else {
this.fatalError = `Unsupported type ${type}`;
return;
}
try {
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') {
this.fatalError = `Unknown type ${toParams.type}`;
return;
}
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
});
}
};
@@ -577,21 +588,44 @@ export default {
overflow: hidden;
height: 100%;
display: flex;
padding: 0 10px;
padding: 0 10px
}
.side-bar-title {
.title-bar {
text-align: center;
font-size: 20px;
margin-bottom: 20px;
color: #607d8b;
}
.title-bar > a {
color: #607d8b;
}
.title-bar > a:hover {
color: black;
text-decoration: none;
}
.main-view-col {
overflow: auto;
flex-grow: 1;
}
.directory-view {
background-color: var(--pankow-color-background);
.dialog-header {
font-weight: 600;
font-size: 1.25rem;
}
.dialog-single-input {
display: block;
width: 100%;
margin-top: 5px;
margin-bottom: 1.5rem;
}
.dialog-single-input-submit {
margin-top: 5px;
}
</style>
@@ -602,4 +636,9 @@ export default {
margin: 0;
}
/* this is actually calculated and in some situations the z-index would have to be higher but for the moment ok, needs fixing in primvue */
.p-dropdown-panel.p-component {
z-index: 5001 !important;
}
</style>
+5 -8
View File
@@ -1,28 +1,28 @@
<template>
<div class="viewer">
<TextViewer ref="textEditor"
<TextEditor ref="textEditor"
v-show="active === 'textEditor'"
:save-handler="saveHandler"
@close="onClose"
:tr="$t"
/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose" :navigation-handler="imageViewerNavigationHandler"/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose"/>
</div>
</template>
<script>
import { TextViewer, ImageViewer } from 'pankow-viewers';
import { TextEditor, ImageViewer } from 'pankow';
import { createDirectoryModel } from '../models/DirectoryModel.js';
import { sanitize } from 'pankow/utils';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
export default {
name: 'Viewer',
components: {
ImageViewer,
TextViewer
TextEditor
},
data() {
return {
@@ -36,9 +36,6 @@ export default {
onClose() {
location.replace('#/home' + location.hash.slice('#/viewer'.length, location.hash.lastIndexOf('/')+1));
},
imageViewerNavigationHandler() {
// nothing to do
},
async saveHandler(item, content) {
await this.directoryModel.save(this.filePath, content);
}

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