2015-07-20 00:09:47 -07:00
'use strict' ;
/* global angular:false */
2016-01-13 16:00:37 +01:00
/* global showdown:false */
2015-07-20 00:09:47 -07:00
2017-09-01 10:25:04 +02:00
// deal with accessToken in the query, this is passed for example on password reset and account setup upon invite
2015-07-20 00:09:47 -07:00
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 ; } , { } ) ;
2017-09-01 10:25:04 +02:00
if ( search . accessToken ) {
localStorage . token = search . accessToken ;
// strip the accessToken and expiresAt, then preserve the rest
delete search . accessToken ;
delete search . expiresAt ;
// this will reload the page as this is not a hash change
window . location . search = encodeURIComponent ( Object . keys ( search ) . map ( function ( key ) { return key + '=' + search [ key ] ; } ) . join ( '&' ) ) ;
}
2015-07-20 00:09:47 -07:00
2016-01-14 15:07:41 +01:00
2015-07-20 00:09:47 -07:00
// create main application module
2017-06-12 13:18:47 +02:00
var app = angular . module ( 'Application' , [ 'ngFitText' , 'ngRoute' , 'ngAnimate' , 'ngSanitize' , 'angular-md5' , 'base64' , 'slick' , 'ui-notification' , 'ui.bootstrap' , 'ui.bootstrap-slider' , 'ngTld' , 'ui.multiselect' ] ) ;
2015-07-20 00:09:47 -07:00
2016-01-14 15:07:41 +01:00
app . config ( [ 'NotificationProvider' , function ( NotificationProvider ) {
NotificationProvider . setOptions ( {
2017-01-28 19:22:56 -08:00
delay : 5000 ,
2016-01-14 15:07:41 +01:00
startTop : 60 ,
2016-01-14 15:53:47 +01:00
positionX : 'left' ,
2017-01-10 15:55:02 +01:00
maxCount : 3 ,
2017-01-12 15:55:45 +01:00
templateUrl : 'notification.html'
2016-01-14 15:07:41 +01:00
} ) ;
} ] ) ;
2015-07-20 00:09:47 -07:00
// setup all major application routes
app . config ( [ '$routeProvider' , function ( $routeProvider ) {
$routeProvider . when ( '/' , {
redirectTo : '/apps'
} ) . when ( '/users' , {
controller : 'UsersController' ,
templateUrl : 'views/users.html'
} ) . when ( '/appstore' , {
controller : 'AppStoreController' ,
templateUrl : 'views/appstore.html'
} ) . when ( '/appstore/:appId' , {
controller : 'AppStoreController' ,
templateUrl : 'views/appstore.html'
} ) . when ( '/apps' , {
controller : 'AppsController' ,
templateUrl : 'views/apps.html'
} ) . when ( '/account' , {
controller : 'AccountController' ,
templateUrl : 'views/account.html'
} ) . when ( '/graphs' , {
controller : 'GraphsController' ,
templateUrl : 'views/graphs.html'
2017-08-17 09:30:31 +02:00
} ) . when ( '/debug' , {
controller : 'DebugController' ,
templateUrl : 'views/debug.html'
2015-11-04 17:04:55 -08:00
} ) . when ( '/certs' , {
controller : 'CertsController' ,
templateUrl : 'views/certs.html'
2017-06-02 10:24:46 +02:00
} ) . when ( '/email' , {
controller : 'EmailController' ,
templateUrl : 'views/email.html'
2015-07-20 00:09:47 -07:00
} ) . when ( '/settings' , {
controller : 'SettingsController' ,
templateUrl : 'views/settings.html'
2016-04-30 18:57:55 -07:00
} ) . when ( '/activity' , {
controller : 'ActivityController' ,
templateUrl : 'views/activity.html'
2015-08-04 11:33:17 +02:00
} ) . when ( '/support' , {
controller : 'SupportController' ,
templateUrl : 'views/support.html'
2016-06-07 22:53:36 +02:00
} ) . when ( '/tokens' , {
controller : 'TokensController' ,
templateUrl : 'views/tokens.html'
2015-07-20 00:09:47 -07:00
} ) . otherwise ( { redirectTo : '/' } ) ;
} ] ) ;
// keep in sync with appdb.js
var ISTATES = {
PENDING _INSTALL : 'pending_install' ,
2016-06-17 19:11:29 -05:00
PENDING _CLONE : 'pending_clone' ,
2015-07-20 00:09:47 -07:00
PENDING _CONFIGURE : 'pending_configure' ,
PENDING _UNINSTALL : 'pending_uninstall' ,
PENDING _RESTORE : 'pending_restore' ,
PENDING _UPDATE : 'pending_update' ,
2015-07-20 10:43:19 -07:00
PENDING _FORCE _UPDATE : 'pending_force_update' ,
2015-07-20 00:09:47 -07:00
PENDING _BACKUP : 'pending_backup' ,
ERROR : 'error' ,
INSTALLED : 'installed'
} ;
var HSTATES = {
HEALTHY : 'healthy' ,
UNHEALTHY : 'unhealthy' ,
ERROR : 'error' ,
DEAD : 'dead'
} ;
app . filter ( 'installError' , function ( ) {
return function ( app ) {
if ( app . installationState === ISTATES . ERROR ) return true ;
if ( app . installationState === ISTATES . INSTALLED ) {
// app.health can also be null to indicate insufficient data
if ( app . health === HSTATES . UNHEALTHY || app . health === HSTATES . ERROR || app . health === HSTATES . DEAD ) return true ;
}
return false ;
} ;
} ) ;
app . filter ( 'installSuccess' , function ( ) {
return function ( app ) {
return app . installationState === ISTATES . INSTALLED ;
} ;
} ) ;
2016-06-07 13:46:45 +02:00
app . filter ( 'activeOAuthClients' , function ( ) {
return function ( clients , user ) {
return clients . filter ( function ( c ) { return user . admin || ( c . activeTokens && c . activeTokens . length > 0 ) ; } ) ;
} ;
} ) ;
2016-11-30 17:31:37 +01:00
app . filter ( 'prettyAppMessage' , function ( ) {
return function ( message ) {
2017-08-01 11:42:20 +02:00
if ( message === 'ETRYAGAIN' ) return 'The DNS record for this location is not setup correctly. Please verify your DNS settings and repair this app.' ;
if ( message === 'DNS Record already exists' ) return 'The DNS record for this location already exists. Manually remove the DNS record and then click on repair.' ;
return message ;
} ;
} ) ;
app . filter ( 'shortAppMessage' , function ( ) {
return function ( message ) {
if ( message === 'ETRYAGAIN' ) return 'DNS record not setup correctly' ;
2016-11-30 17:31:37 +01:00
return message ;
} ;
} ) ;
2016-12-30 12:51:30 +01:00
app . filter ( 'prettyMemory' , function ( ) {
return function ( memory ) {
// Adjust the default memory limit if it changes
return memory ? Math . floor ( memory / 1024 / 1024 ) : 256 ;
} ;
} ) ;
2015-07-20 00:09:47 -07:00
app . filter ( 'installationActive' , function ( ) {
return function ( app ) {
if ( app . installationState === ISTATES . ERROR ) return false ;
if ( app . installationState === ISTATES . INSTALLED ) return false ;
return true ;
} ;
} ) ;
app . filter ( 'installationStateLabel' , function ( ) {
2017-01-10 13:00:02 +01:00
// for better DNS errors
function detailedError ( app ) {
if ( app . installationProgress === 'ETRYAGAIN' ) return 'DNS Error' ;
return 'Error' ;
}
2015-07-20 00:09:47 -07:00
return function ( app ) {
2017-12-15 16:58:38 +05:30
var waiting = app . progress === 0 ? ' (Pending)' : '' ;
2015-07-20 00:09:47 -07:00
switch ( app . installationState ) {
2016-06-17 19:11:29 -05:00
case ISTATES . PENDING _INSTALL :
case ISTATES . PENDING _CLONE :
return 'Installing' + waiting ;
2015-09-08 15:49:10 +02:00
case ISTATES . PENDING _CONFIGURE : return 'Configuring' + waiting ;
case ISTATES . PENDING _UNINSTALL : return 'Uninstalling' + waiting ;
case ISTATES . PENDING _RESTORE : return 'Restoring' + waiting ;
case ISTATES . PENDING _UPDATE : return 'Updating' + waiting ;
case ISTATES . PENDING _FORCE _UPDATE : return 'Updating' + waiting ;
case ISTATES . PENDING _BACKUP : return 'Backing up' + waiting ;
2017-01-10 13:00:02 +01:00
case ISTATES . ERROR : return detailedError ( app ) ;
2015-07-20 00:09:47 -07:00
case ISTATES . INSTALLED : {
if ( app . runState === 'running' ) {
if ( ! app . health ) return 'Starting...' ; // no data yet
if ( app . health === HSTATES . HEALTHY ) return 'Running' ;
return 'Not responding' ; // dead/exit/unhealthy
} else if ( app . runState === 'pending_start' ) return 'Starting...' ;
else if ( app . runState === 'pending_stop' ) return 'Stopping...' ;
else if ( app . runState === 'stopped' ) return 'Stopped' ;
else return app . runState ;
break ;
}
default : return app . installationState ;
}
} ;
} ) ;
app . filter ( 'readyToUpdate' , function ( ) {
return function ( apps ) {
return apps . every ( function ( app ) {
return ( app . installationState === ISTATES . ERROR ) || ( app . installationState === ISTATES . INSTALLED ) ;
} ) ;
} ;
} ) ;
app . filter ( 'inProgressApps' , function ( ) {
return function ( apps ) {
return apps . filter ( function ( app ) {
return app . installationState !== ISTATES . ERROR && app . installationState !== ISTATES . INSTALLED ;
} ) ;
} ;
} ) ;
2016-02-25 15:53:36 +01:00
app . filter ( 'ignoreAdminGroup' , function ( ) {
return function ( groups ) {
return groups . filter ( function ( group ) {
if ( group . id ) return group . id !== 'admin' ;
return group !== 'admin' ;
} ) ;
} ;
} ) ;
2015-07-20 00:09:47 -07:00
app . filter ( 'applicationLink' , function ( ) {
return function ( app ) {
if ( app . installationState === ISTATES . INSTALLED && app . health === HSTATES . HEALTHY ) {
return 'https://' + app . fqdn ;
} else {
return '' ;
}
} ;
} ) ;
app . filter ( 'prettyHref' , function ( ) {
return function ( input ) {
if ( ! input ) return input ;
if ( input . indexOf ( 'http://' ) === 0 ) return input . slice ( 'http://' . length ) ;
if ( input . indexOf ( 'https://' ) === 0 ) return input . slice ( 'https://' . length ) ;
return input ;
} ;
} ) ;
app . filter ( 'prettyDate' , function ( ) {
// http://ejohn.org/files/pretty.js
return function prettyDate ( time ) {
var date = new Date ( time ) ,
diff = ( ( ( new Date ( ) ) . getTime ( ) - date . getTime ( ) ) / 1000 ) + 30 , // add 30seconds for clock skew
day _diff = Math . floor ( diff / 86400 ) ;
2016-07-09 13:06:59 -07:00
if ( isNaN ( day _diff ) || day _diff < 0 )
2017-03-02 14:34:14 -08:00
return 'just now' ;
2015-07-20 00:09:47 -07:00
2016-01-13 16:00:37 +01:00
return day _diff === 0 && (
2015-07-20 00:09:47 -07:00
diff < 60 && 'just now' ||
diff < 120 && '1 minute ago' ||
diff < 3600 && Math . floor ( diff / 60 ) + ' minutes ago' ||
diff < 7200 && '1 hour ago' ||
diff < 86400 && Math . floor ( diff / 3600 ) + ' hours ago' ) ||
2016-01-13 16:00:37 +01:00
day _diff === 1 && 'Yesterday' ||
2015-07-20 00:09:47 -07:00
day _diff < 7 && day _diff + ' days ago' ||
2016-07-09 13:06:59 -07:00
day _diff < 31 && Math . ceil ( day _diff / 7 ) + ' weeks ago' ||
day _diff < 365 && Math . round ( day _diff / 30 ) + ' months ago' ||
Math . round ( day _diff / 365 ) + ' years ago' ;
2015-07-20 00:09:47 -07:00
} ;
} ) ;
app . filter ( 'markdown2html' , function ( ) {
2016-01-13 16:16:40 +01:00
var converter = new showdown . Converter ( {
extensions : [ 'targetblank' ] ,
simplifiedAutoLink : true ,
strikethrough : true ,
tables : true
} ) ;
2015-07-20 00:09:47 -07:00
return function ( text ) {
return converter . makeHtml ( text ) ;
} ;
} ) ;
2016-11-24 15:33:45 +01:00
app . filter ( 'postInstallMessage' , function ( ) {
var SSO _MARKER = '=== sso ===' ;
return function ( text , app ) {
2016-11-24 15:42:41 +01:00
if ( ! text ) return '' ;
2016-11-24 15:33:45 +01:00
if ( ! app ) return text ;
var parts = text . split ( SSO _MARKER ) ;
2017-03-08 15:02:11 -08:00
if ( parts . length === 1 ) {
// [^] matches even newlines. '?' makes it non-greedy
if ( app . sso ) return text . replace ( /\<nosso\>[^]*?\<\/nosso\>/g , '' ) ;
else return text . replace ( /\<sso\>[^]*?\<\/sso\>/g , '' ) ;
}
2016-11-24 15:33:45 +01:00
if ( app . sso ) return parts [ 1 ] ;
else return parts [ 0 ] ;
} ;
} ) ;
2017-03-07 12:44:17 -08:00
// keep this in sync with eventlog.js and CLI tool
2016-04-30 19:49:50 -07:00
var ACTION _ACTIVATE = 'cloudron.activate' ;
var ACTION _APP _CONFIGURE = 'app.configure' ;
var ACTION _APP _INSTALL = 'app.install' ;
var ACTION _APP _RESTORE = 'app.restore' ;
var ACTION _APP _UNINSTALL = 'app.uninstall' ;
var ACTION _APP _UPDATE = 'app.update' ;
2017-03-02 17:14:56 +01:00
var ACTION _APP _LOGIN = 'app.login' ;
2016-05-01 11:42:12 -07:00
var ACTION _BACKUP _FINISH = 'backup.finish' ;
var ACTION _BACKUP _START = 'backup.start' ;
2017-10-01 09:29:42 -07:00
var ACTION _BACKUP _CLEANUP = 'backup.cleanup' ;
2016-04-30 22:27:33 -07:00
var ACTION _CERTIFICATE _RENEWAL = 'certificate.renew' ;
2016-04-30 19:49:50 -07:00
var ACTION _CLI _MODE = 'settings.climode' ;
2016-05-02 09:39:38 -07:00
var ACTION _START = 'cloudron.start' ;
2016-04-30 19:49:50 -07:00
var ACTION _UPDATE = 'cloudron.update' ;
var ACTION _USER _ADD = 'user.add' ;
2016-04-30 23:16:37 -07:00
var ACTION _USER _LOGIN = 'user.login' ;
2016-04-30 19:49:50 -07:00
var ACTION _USER _REMOVE = 'user.remove' ;
var ACTION _USER _UPDATE = 'user.update' ;
app . filter ( 'eventLogDetails' , function ( ) {
2017-03-07 12:44:17 -08:00
// NOTE: if you change this, the CLI tool (cloudron machine eventlog) probably needs fixing as well
2016-04-30 19:49:50 -07:00
return function ( eventLog ) {
2016-05-01 11:42:12 -07:00
var source = eventLog . source ;
2016-04-30 19:49:50 -07:00
var data = eventLog . data ;
2016-05-02 09:32:39 -07:00
var errorMessage = data . errorMessage ;
2016-04-30 19:49:50 -07:00
switch ( eventLog . action ) {
2016-05-02 09:32:39 -07:00
case ACTION _ACTIVATE : return 'Cloudron activated' ;
case ACTION _APP _CONFIGURE : return 'App ' + data . appId + ' was configured' ;
2016-05-02 10:33:29 -07:00
case ACTION _APP _INSTALL : return 'App ' + data . manifest . id + '@' + data . manifest . version + ' installed at ' + data . location + ' with id ' + data . appId ;
case ACTION _APP _RESTORE : return 'App ' + data . appId + ' restored' ;
case ACTION _APP _UNINSTALL : return 'App ' + data . appId + ' uninstalled' ;
case ACTION _APP _UPDATE : return 'App ' + data . appId + ' updated to version ' + data . toManifest . id + '@' + data . toManifest . version ;
2017-03-02 17:14:56 +01:00
case ACTION _APP _LOGIN : return 'App ' + data . appId + ' logged in' ;
2016-05-02 09:32:39 -07:00
case ACTION _BACKUP _START : return 'Backup started' ;
2016-05-02 10:33:29 -07:00
case ACTION _BACKUP _FINISH : return 'Backup finished. ' + ( errorMessage ? ( 'error: ' + errorMessage ) : ( 'id: ' + data . filename ) ) ;
2017-10-01 09:29:42 -07:00
case ACTION _BACKUP _CLEANUP : return 'Backup ' + data . backup . id + ' removed' ;
2016-05-02 09:32:39 -07:00
case ACTION _CERTIFICATE _RENEWAL : return 'Certificate renewal for ' + data . domain + ( errorMessage ? ' failed' : 'succeeded' ) ;
2016-04-30 19:49:50 -07:00
case ACTION _CLI _MODE : return 'CLI mode was ' + ( data . enabled ? 'enabled' : 'disabled' ) ;
2016-05-02 09:39:38 -07:00
case ACTION _START : return 'Cloudron started with version ' + data . version ;
2016-05-02 09:32:39 -07:00
case ACTION _UPDATE : return 'Updating to version ' + data . boxUpdateInfo . version ;
2016-05-02 10:33:29 -07:00
case ACTION _USER _ADD : return 'User ' + data . email + ' added with id ' + data . userId ;
case ACTION _USER _LOGIN : return 'User ' + data . userId + ' logged in' ;
2016-05-02 09:32:39 -07:00
case ACTION _USER _REMOVE : return 'User ' + data . userId + ' removed' ;
case ACTION _USER _UPDATE : return 'User ' + data . userId + ' updated' ;
2016-04-30 19:49:50 -07:00
default : return eventLog . action ;
}
} ;
} ) ;
2015-07-20 00:09:47 -07:00
// custom directive for dynamic names in forms
// See http://stackoverflow.com/questions/23616578/issue-registering-form-control-with-interpolated-name#answer-23617401
app . directive ( 'laterName' , function ( ) { // (2)
return {
restrict : 'A' ,
require : [ '?ngModel' , '^?form' ] , // (3)
link : function postLink ( scope , elem , attrs , ctrls ) {
attrs . $set ( 'name' , attrs . laterName ) ;
var modelCtrl = ctrls [ 0 ] ; // (3)
var formCtrl = ctrls [ 1 ] ; // (3)
if ( modelCtrl && formCtrl ) {
modelCtrl . $name = attrs . name ; // (4)
formCtrl . $addControl ( modelCtrl ) ; // (2)
scope . $on ( '$destroy' , function ( ) {
formCtrl . $removeControl ( modelCtrl ) ; // (5)
} ) ;
}
}
} ;
} ) ;
2016-01-25 16:21:20 +01:00
app . run ( [ '$route' , '$rootScope' , '$location' , function ( $route , $rootScope , $location ) {
var original = $location . path ;
$location . path = function ( path , reload ) {
if ( reload === false ) {
var lastRoute = $route . current ;
var un = $rootScope . $on ( '$locationChangeSuccess' , function ( ) {
$route . current = lastRoute ;
un ( ) ;
} ) ;
}
return original . apply ( $location , [ path ] ) ;
} ;
2016-04-04 18:35:38 +02:00
} ] ) ;
app . directive ( 'ngClickSelect' , function ( ) {
return {
restrict : 'AC' ,
link : function ( scope , element , attrs ) {
element . bind ( 'click' , function ( ) {
var selection = window . getSelection ( ) ;
var range = document . createRange ( ) ;
range . selectNodeContents ( this ) ;
selection . removeAllRanges ( ) ;
selection . addRange ( range ) ;
} ) ;
}
} ;
} ) ;
2016-06-09 10:16:13 -07:00
2016-09-22 14:52:29 +02:00
app . directive ( 'ngClickReveal' , function ( ) {
return {
restrict : 'A' ,
link : function ( scope , element , attrs ) {
element . addClass ( 'hand' ) ;
2016-09-22 15:26:04 +02:00
var value = '' ;
scope . $watch ( attrs . ngClickReveal , function ( newValue , oldValue ) {
if ( newValue !== oldValue ) {
element . html ( '<i>hidden</i>' ) ;
value = newValue ;
}
} ) ;
2016-09-22 14:52:29 +02:00
element . bind ( 'click' , function ( ) {
2016-09-22 15:26:04 +02:00
element . text ( value ) ;
2016-09-22 14:52:29 +02:00
} ) ;
}
} ;
} ) ;
2016-06-09 10:16:13 -07:00
// https://codepen.io/webmatze/pen/isuHh
app . directive ( 'tagInput' , function ( ) {
return {
restrict : 'E' ,
scope : {
2016-06-09 10:33:10 -07:00
inputTags : '=taglist'
2016-06-09 10:16:13 -07:00
} ,
link : function ( $scope , element , attrs ) {
$scope . defaultWidth = 200 ;
2016-06-10 11:46:25 -07:00
$scope . tagText = '' ; // current tag being edited
2016-06-09 10:16:13 -07:00
$scope . placeholder = attrs . placeholder ;
$scope . tagArray = function ( ) {
if ( $scope . inputTags === undefined ) {
return [ ] ;
}
return $scope . inputTags . split ( ',' ) . filter ( function ( tag ) {
return tag !== '' ;
} ) ;
} ;
$scope . addTag = function ( ) {
var tagArray ;
if ( $scope . tagText . length === 0 ) {
return ;
}
tagArray = $scope . tagArray ( ) ;
tagArray . push ( $scope . tagText ) ;
$scope . inputTags = tagArray . join ( ',' ) ;
return $scope . tagText = '' ;
} ;
$scope . deleteTag = function ( key ) {
var tagArray ;
tagArray = $scope . tagArray ( ) ;
if ( tagArray . length > 0 && $scope . tagText . length === 0 && key === undefined ) {
tagArray . pop ( ) ;
} else {
if ( key !== undefined ) {
tagArray . splice ( key , 1 ) ;
}
}
return $scope . inputTags = tagArray . join ( ',' ) ;
} ;
$scope . $watch ( 'tagText' , function ( newVal , oldVal ) {
var tempEl ;
if ( ! ( newVal === oldVal && newVal === undefined ) ) {
tempEl = $ ( '<span>' + newVal + '</span>' ) . appendTo ( 'body' ) ;
$scope . inputWidth = tempEl . width ( ) + 5 ;
if ( $scope . inputWidth < $scope . defaultWidth ) {
$scope . inputWidth = $scope . defaultWidth ;
}
return tempEl . remove ( ) ;
}
} ) ;
element . bind ( 'keydown' , function ( e ) {
2016-06-09 16:11:57 -07:00
var key = e . which ;
2016-06-09 10:16:13 -07:00
if ( key === 9 || key === 13 ) {
e . preventDefault ( ) ;
}
if ( key === 8 ) {
return $scope . $apply ( 'deleteTag()' ) ;
}
} ) ;
2016-06-10 12:35:57 -07:00
element . bind ( 'keyup' , function ( e ) {
2016-06-09 16:11:57 -07:00
var key = e . which ;
2016-09-28 13:06:02 -07:00
if ( key === 9 || key === 13 || key === 32 || key === 188 ) {
2016-06-09 10:16:13 -07:00
e . preventDefault ( ) ;
return $scope . $apply ( 'addTag()' ) ;
}
} ) ;
} ,
2016-06-09 16:11:57 -07:00
template :
2016-06-10 11:46:25 -07:00
'<div class="tag-input-container">' +
2016-06-09 16:11:57 -07:00
'<div class="input-tag" data-ng-repeat="tag in tagArray()">' +
'{{tag}}' +
'<div class="delete-tag" data-ng-click="deleteTag($index)">×</div>' +
'</div>' +
2016-06-10 12:35:57 -07:00
'<input type="text" data-ng-model="tagText" ng-blur="addTag()" placeholder="{{placeholder}}"/>' +
2016-06-09 16:11:57 -07:00
'</div>'
2016-06-09 10:16:13 -07:00
} ;
} ) ;
2017-05-31 14:06:35 +02:00
app . config ( [ 'fitTextConfigProvider' , function ( fitTextConfigProvider ) {
fitTextConfigProvider . config = {
2017-06-01 15:16:38 +02:00
loadDelay : 250 ,
2017-06-01 15:30:13 +02:00
compressor : 0.9 ,
2017-05-31 14:06:35 +02:00
min : 8 ,
max : 24
} ;
} ] ) ;