'use strict'; /* global angular */ /* global $ */ /* global async */ /* global RSTATES */ /* global ISTATES */ /* global ERROR */ /* global Chart */ /* global Clipboard */ /* global SECRET_PLACEHOLDER */ angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) { // List is from http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region $scope.s3Regions = [ { name: 'Asia Pacific (Mumbai)', value: 'ap-south-1' }, { name: 'Asia Pacific (Osaka-Local)', value: 'ap-northeast-3' }, { name: 'Asia Pacific (Seoul)', value: 'ap-northeast-2' }, { name: 'Asia Pacific (Singapore)', value: 'ap-southeast-1' }, { name: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' }, { name: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' }, { name: 'Canada (Central)', value: 'ca-central-1' }, { name: 'China (Beijing)', value: 'cn-north-1' }, { name: 'China (Ningxia)', value: 'cn-northwest-1' }, { name: 'EU (Frankfurt)', value: 'eu-central-1' }, { name: 'EU (Ireland)', value: 'eu-west-1' }, { name: 'EU (London)', value: 'eu-west-2' }, { name: 'EU (Paris)', value: 'eu-west-3' }, { name: 'EU (Stockholm)', value: 'eu-north-1' }, { name: 'South America (São Paulo)', value: 'sa-east-1' }, { name: 'US East (N. Virginia)', value: 'us-east-1' }, { name: 'US East (Ohio)', value: 'us-east-2' }, { name: 'US West (N. California)', value: 'us-west-1' }, { name: 'US West (Oregon)', value: 'us-west-2' }, ]; $scope.wasabiRegions = [ { name: 'EU Central 1', value: 'https://s3.eu-central-1.wasabisys.com' }, { name: 'US East 1', value: 'https://s3.us-east-1.wasabisys.com' }, { name: 'US East 2', value: 'https://s3.us-east-2.wasabisys.com ' }, { name: 'US West 1', value: 'https://s3.us-west-1.wasabisys.com' } ]; $scope.doSpacesRegions = [ { name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' }, { name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' }, { name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' }, { name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' }, { name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' }, { name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' } ]; $scope.exoscaleSosRegions = [ { name: 'AT-VIE-1', value: 'https://sos-at-vie-1.exo.io' }, { name: 'CH-DK-2', value: 'https://sos-ch-dk-2.exo.io' }, { name: 'CH-GVA-2', value: 'https://sos-ch-gva-2.exo.io' }, { name: 'DE-FRA-1', value: 'https://sos-de-fra-1.exo.io' }, ]; // https://www.scaleway.com/docs/object-storage-feature/ $scope.scalewayRegions = [ { name: 'FR-PAR', value: 'https://s3.fr-par.scw.cloud', region: 'fr-par' }, // default { name: 'NL-AMS', value: 'https://s3.nl-ams.scw.cloud', region: 'nl-ams' } ]; $scope.linodeRegions = [ { name: 'Newark', value: 'us-east-1.linodeobjects.com', region: 'us-east-1' }, // default { name: 'Frankfurt', value: 'eu-central-1.linodeobjects.com', region: 'us-east-1' }, { name: 'Singapore', value: 'ap-south-1.linodeobjects.com', region: 'us-east-1' }, ]; // note: ovh also has a storage endpoint but that only supports path style access $scope.ovhRegions = [ { 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' }, ]; // https://devops.ionos.com/api/s3/ $scope.ionosRegions = [ { name: 'DE', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default ]; $scope.vultrRegions = [ { name: 'New Jersey', value: 'https://ewr1.vultrobjects.com', region: 'us-east-1' }, // default ]; $scope.storageProvider = [ { name: 'Amazon S3', value: 's3' }, { name: 'Backblaze B2 (S3 API)', value: 'backblaze-b2' }, { name: 'CIFS Mount', value: 'cifs' }, { name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' }, { name: 'Exoscale SOS', value: 'exoscale-sos' }, // { name: 'EXT4', value: 'ext4' }, { name: 'Filesystem', value: 'filesystem' }, // { name: 'Filesystem (Mountpoint)', value: 'mountpoint' }, { name: 'Google Cloud Storage', value: 'gcs' }, { name: 'IONOS (Profitbricks)', value: 'ionos-objectstorage' }, { name: 'Linode Object Storage', value: 'linode-objectstorage' }, { name: 'Minio', value: 'minio' }, { name: 'NFS Mount', value: 'nfs' }, { name: 'OVH Object Storage', value: 'ovh-objectstorage' }, { name: 'S3 API Compatible (v4)', value: 's3-v4-compat' }, { name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' }, { name: 'SSHFS Mount', value: 'sshfs' }, { name: 'UpCloud Object Storage', value: 'upcloud-objectstorage' }, { name: 'Vultr Object Storage', value: 'vultr-objectstorage' }, // { name: 'No-op (Only for testing)', value: 'noop' }, { name: 'Wasabi', value: 'wasabi' } ]; $scope.formats = [ { name: 'Tarball (zipped)', value: 'tgz' }, { name: 'rsync', value: 'rsync' } ]; // Avoid full reload on path change // https://stackoverflow.com/a/22614334 // reloadOnUrl: false in $routeProvider did not work! var lastRoute = $route.current; $scope.$on('$locationChangeSuccess', function (/* event */) { if (lastRoute.$$route.originalPath === $route.current.$$route.originalPath) { $route.current = lastRoute; } }); var appId = $routeParams.appId; if (!appId) return $location.path('/apps'); $scope.view = ''; $scope.app = null; $scope.config = Client.getConfig(); $scope.user = Client.getUserInfo(); // note: these variables will remain empty for operators $scope.domains = []; $scope.volumes = []; $scope.groups = []; $scope.users = []; $scope.backupConfig = null; $scope.HOST_PORT_MIN = 1; $scope.HOST_PORT_MAX = 65535; $scope.ROBOTS_DISABLE_INDEXING_TEMPLATE = '# Disable search engine indexing\n\nUser-agent: *\nDisallow: /'; $scope.setView = function (view, skipViewShow) { if ($scope.view === view) return; $route.updateParams({ view: view }); if (!skipViewShow) $scope[view].show(); $scope.view = view; }; $scope.stopAppTask = function (taskId) { Client.stopTask(taskId, function (error) { // we can ignore a call trying to cancel an already done task if (error && error.statusCode !== 409) Client.error(error); }); }; $scope.postInstallMessage = { confirmed: false, openApp: false, show: function (openApp) { $scope.postInstallMessage.confirmed = false; $scope.postInstallMessage.openApp = !!openApp; if (!$scope.app.manifest.postInstallMessage) return; $('#postInstallModal').modal('show'); }, submit: function () { if (!$scope.postInstallMessage.confirmed) return; $scope.app.pendingPostInstallConfirmation = false; delete localStorage['confirmPostInstall_' + $scope.app.id]; $('#postInstallModal').modal('hide'); } }; $scope.sftpInfo = { show: function () { $('#sftpInfoModal').modal('show'); } }; $scope.display = { busy: false, error: {}, success: false, tags: '', label: '', icon: { data: null }, iconUrl: function () { if (!$scope.app) return ''; if ($scope.display.icon.data === '__original__') { // user clicked reset return $scope.app.iconUrl + '&original=true'; } else if ($scope.display.icon.data) { // user uploaded icon return $scope.display.icon.data; } else { // current icon return $scope.app.iconUrl; } }, resetCustomIcon: function () { $scope.display.icon.data = '__original__'; }, showCustomIconSelector: function () { $('#iconFileInput').click(); }, show: function () { var app = $scope.app; $scope.display.error = {}; // translate for tag-input $scope.display.tags = app.tags ? app.tags.join(' ') : ''; $scope.display.label = $scope.app.label || ''; $scope.display.icon = { data: null }; }, submit: function () { $scope.display.busy = true; $scope.display.error = {}; function done(error) { if (error) Client.error(error); $scope.displayForm.$setPristine(); $scope.display.success = true; refreshApp($scope.app.id, function (error) { if (error) Client.error(error); $scope.display.show(); // "refresh" view with latest data $timeout(function () { $scope.display.busy = false; }, 1000); }); } var NOOP = function (next) { return next(); }; var configureLabel = $scope.display.label === $scope.app.label ? NOOP : Client.configureApp.bind(null, $scope.app.id, 'label', { label: $scope.display.label }); configureLabel(function (error) { if (error) return done(error); var tags = $scope.display.tags.split(' ').map(function (t) { return t.trim(); }).filter(function (t) { return !!t; }); var configureTags = angular.equals(tags, $scope.app.tags) ? NOOP : Client.configureApp.bind(null, $scope.app.id, 'tags', { tags: tags }); configureTags(function (error) { if (error) return done(error); // skip if icon is unchanged if ($scope.display.icon.data === null) return done(); var icon; if ($scope.display.icon.data === '__original__') { // user reset the icon icon = ''; } else if ($scope.display.icon.data) { // user loaded custom icon icon = $scope.display.icon.data.replace(/^data:image\/[a-z]+;base64,/, ''); } Client.configureApp($scope.app.id, 'icon', { icon: icon }, function (error) { if (error) return done(error); done(); }); }); }); } }; $scope.location = { busy: false, error: {}, domainCollisions: [], domain: null, location: '', alternateDomains: [], aliasDomains: [], portBindings: {}, portBindingsEnabled: {}, portBindingsInfo: {}, addAlternateDomain: function (event) { event.preventDefault(); $scope.location.alternateDomains.push({ domain: $scope.domains.filter(function (d) { return d.domain === $scope.app.domain; })[0], // pre-select app's domain by default subdomain: '' }); setTimeout(function () { document.getElementById('alternateDomainsInput-' + ($scope.location.alternateDomains.length-1)).focus(); }, 200); }, delAlternateDomain: function (event, index) { event.preventDefault(); $scope.location.alternateDomains.splice(index, 1); }, addAliasDomain: function (event) { event.preventDefault(); $scope.location.aliasDomains.push({ domain: $scope.domains.filter(function (d) { return d.domain === $scope.app.domain; })[0], // pre-select app's domain by default subdomain: '' }); setTimeout(function () { document.getElementById('aliasDomainsInput-' + ($scope.location.aliasDomains.length-1)).focus(); }, 200); }, delAliasDomain: function (event, index) { event.preventDefault(); $scope.location.aliasDomains.splice(index, 1); }, show: function () { var app = $scope.app; $scope.location.error = {}; $scope.location.domainCollisions = []; $scope.location.location = app.location; $scope.location.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0]; $scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information $scope.location.alternateDomains = app.alternateDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };}); $scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };}); // fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port for (var env in $scope.location.portBindingsInfo) { if (app.portBindings && app.portBindings[env]) { $scope.location.portBindings[env] = app.portBindings[env]; $scope.location.portBindingsEnabled[env] = true; } else { $scope.location.portBindings[env] = $scope.location.portBindingsInfo[env].defaultValue || 0; $scope.location.portBindingsEnabled[env] = false; } } }, submit: function (overwriteDns) { $('#domainCollisionsModal').modal('hide'); $scope.location.busy = true; $scope.location.error = {}; $scope.location.domainCollisions = []; // 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]; } } var data = { overwriteDns: !!overwriteDns, location: $scope.location.location, domain: $scope.location.domain.domain, portBindings: portBindings, alternateDomains: $scope.location.alternateDomains.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 };}) }; // pre-flight only for changed domains var domains = []; if ($scope.app.domain !== data.domain || $scope.app.location !== data.location) domains.push({ subdomain: data.location, domain: data.domain, type: 'main' }); data.alternateDomains.forEach(function (a) { if ($scope.app.alternateDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return; domains.push({ subdomain: a.subdomain, domain: a.domain, type: 'redirect' }); }); data.aliasDomains.forEach(function (a) { if ($scope.app.aliasDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return; domains.push({ subdomain: a.subdomain, domain: a.domain, type: 'alias' }); }); async.eachSeries(domains, function (domain, callback) { if (overwriteDns) return callback(); Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) { if (error) return callback(error); if (result.error) { if (domain.type === 'main') { $scope.location.error.location = domain.domain + ' ' + result.error.message; } else if (domain.type === 'alias') { $scope.location.error.aliasDomains = domain.domain + ' ' + result.error.message; } else { $scope.location.error.alternateDomains = domain.domain + ' ' + result.error.message; } $scope.location.busy = false; return; } if (result.needsOverwrite) $scope.location.domainCollisions.push(domain); callback(); }); }, function (error) { if (error) { $scope.location.busy = false; return Client.error(error); } if ($scope.location.domainCollisions.length) { $scope.location.busy = false; return $('#domainCollisionsModal').modal('show'); } Client.configureApp($scope.app.id, 'location', data, function (error) { if (error && (error.statusCode === 409 || error.statusCode === 400)) { if ((error.subdomain && error.domain) || error.field === 'location') { if (data.domain === error.domain && data.location === error.subdomain) { // the primary $scope.location.error.location = error.message; $scope.locationForm.$setPristine(); } else { // FIXME: check error in aliasDomains $scope.location.error.alternateDomains = error.message; } } else if (error.portName || error.field === 'portBindings') { $scope.location.error.port = error.message; } $scope.location.busy = false; return; } if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $scope.locationForm.$setPristine(); $timeout(function () { $scope.location.busy = false; }, 1000); }); }); }); } }; $scope.access = { busy: false, error: {}, success: false, ftp: false, ssoAuth: false, accessRestrictionOption: 'any', accessRestrictionOptionCur: 'any', accessRestriction: { users: [], groups: [] }, operators: { users: [], groups: [] }, isAccessRestrictionValid: function () { var tmp = $scope.access.accessRestriction; return !!(tmp.users.length || tmp.groups.length); }, show: function () { var app = $scope.app; $scope.access.error = {}; $scope.access.ftp = app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp; $scope.access.ssoAuth = (app.manifest.addons['ldap'] || app.manifest.addons['proxyAuth']) && app.sso; $scope.access.accessRestrictionOption = app.accessRestriction ? 'groups' : 'any'; $scope.access.accessRestrictionOptionCur = app.accessRestriction ? 'groups' : 'any'; $scope.access.accessRestriction = { users: [], groups: [] }; $scope.access.operators = { users: [], groups: [] }; var userSet, groupSet; if (app.accessRestriction) { userSet = {}; app.accessRestriction.users.forEach(function (uid) { userSet[uid] = true; }); $scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.access.accessRestriction.users.push(u); }); groupSet = {}; if (app.accessRestriction.groups) app.accessRestriction.groups.forEach(function (gid) { groupSet[gid] = true; }); $scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.access.accessRestriction.groups.push(g); }); } if (app.operators) { userSet = {}; app.operators.users.forEach(function (uid) { userSet[uid] = true; }); $scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.access.operators.users.push(u); }); groupSet = {}; if (app.operators.groups) app.operators.groups.forEach(function (gid) { groupSet[gid] = true; }); $scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.access.operators.groups.push(g); }); } }, submit: function () { $scope.access.busy = true; $scope.access.error = {}; var accessRestriction = null; if ($scope.access.accessRestrictionOption === 'groups') { accessRestriction = { users: [], groups: [] }; accessRestriction.users = $scope.access.accessRestriction.users.map(function (u) { return u.id; }); accessRestriction.groups = $scope.access.accessRestriction.groups.map(function (g) { return g.id; }); } var operators = null; if ($scope.access.operators.users.length || $scope.access.operators.groups.length) { operators = { users: [], groups: [] }; operators.users = $scope.access.operators.users.map(function (u) { return u.id; }); operators.groups = $scope.access.operators.groups.map(function (g) { return g.id; }); } async.series([ function (callback) { if ($scope.access.accessRestrictionOption === $scope.access.accessRestrictionOptionCur && !$scope.accessForm.accessUsersSelect.$dirty && !$scope.accessForm.accessGroupsSelect.$dirty) return callback(); Client.configureApp($scope.app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback); }, function (callback) { if (!$scope.accessForm.operatorsUsersSelect.$dirty && !$scope.accessForm.operatorsGroupsSelect.$dirty) return callback(); Client.configureApp($scope.app.id, 'operators', { operators: operators }, callback); } ], function (error) { if (error) return Client.error(error); $scope.accessForm.$setPristine(); $scope.access.accessRestrictionOptionCur = $scope.access.accessRestrictionOption; $scope.access.success = true; $scope.access.busy = false; }); } }; $scope.resources = { error: {}, busy: false, currentMemoryLimit: 0, memoryLimit: 0, memoryTicks: [], busyCpuShares: false, currentCpuShares: 0, cpuShares: 0, show: function () { var app = $scope.app; $scope.resources.error = {}; $scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024); $scope.resources.memoryLimit = $scope.resources.currentMemoryLimit; $scope.resources.currentCpuShares = $scope.resources.cpuShares = app.cpuShares; Client.getAppLimits(app.id, function (error, limits) { if (error) return console.error(error); // 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 = []; 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); } }); }, submitMemoryLimit: function () { $scope.resources.busy = true; $scope.resources.error = {}; var memoryLimit = $scope.resources.memoryLimit === $scope.resources.memoryTicks[0] ? 0 : $scope.resources.memoryLimit; Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit: memoryLimit }, function (error) { if (error && error.statusCode === 400) { $scope.resources.busy = false; $scope.resources.error.memoryLimit = true; return; } if (error) return Client.error(error); $scope.resources.currentMemoryLimit = $scope.resources.memoryLimit; refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busy = false; }, 1000); }); }); }, submitCpuShares: function () { $scope.resources.busyCpuShares = true; $scope.resources.error = {}; Client.configureApp($scope.app.id, 'cpu_shares', { cpuShares: $scope.resources.cpuShares }, function (error) { if (error) return Client.error(error); $scope.resources.currentCpuShares = $scope.resources.cpuShares; refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busyCpuShares = false; }, 1000); }); }); }, }; $scope.storage = { error: {}, busy: false, busyDataDir: false, dataDir: null, busyBinds: false, mounts: [], // { volume, readOnly } show: function () { var app = $scope.app; $scope.storage.error = {}; $scope.storage.dataDir = app.dataDir; $scope.storage.mounts = []; app.mounts.forEach(function (mount) { // { volumeId, readOnly } var volume = $scope.volumes.find(function (v) { return v.id === mount.volumeId; }); $scope.storage.mounts.push({ volume: volume, readOnly: mount.readOnly }); }); }, submitDataDir: function () { $scope.storage.busyDataDir = true; $scope.storage.error = {}; Client.configureApp($scope.app.id, 'data_dir', { dataDir: $scope.storage.dataDir || null }, function (error) { if (error && error.statusCode === 400) { $scope.storage.error.dataDir = error.message; $scope.storage.busyDataDir = false; return; } if (error) return Client.error(error); $scope.storageDataDirForm.$setPristine(); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.storage.busyDataDir = false; }, 1000); }); }); }, addMount: function (event) { event.preventDefault(); $scope.storage.mounts.push({ volume: $scope.volumes[0], readOnly: true }); }, delMount: function (event, index) { event.preventDefault(); $scope.storage.mounts.splice(index, 1); }, submitMounts: function () { $scope.storage.busyMounts = true; $scope.storage.error = {}; var data = []; $scope.storage.mounts.forEach(function (mount) { data.push({ volumeId: mount.volume.id, readOnly: mount.readOnly }); }); Client.configureApp($scope.app.id, 'mounts', { mounts: data }, function (error) { if (error && error.statusCode === 400) { $scope.storage.error.mounts = error.message; $scope.storage.busyMounts = false; return; } if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.storage.busyMounts = false; }, 1000); }); }); } }; $scope.graphs = { error: {}, period: 12, // set as 12 because disk graphs is only collected twice a day memoryChart: null, diskChart: null, setPeriod: function (hours) { $scope.graphs.period = hours; $scope.graphs.show(); }, show: function () { // both in minutes var timePeriod = $scope.graphs.period * 60; var timeBucketSize = $scope.graphs.period > 24 ? (6*60) : 5; function fillGraph(canvasId, data, label, chartPropertyName, max) { if (!data) return; // no data available yet // fill holes with previous value var cur = 0; data.datapoints.forEach(function (d) { if (d[0] === null) d[0] = cur; else cur = d[0]; }); // translate the data from bytes to MB var datapoints = data.datapoints.map(function (d) { return parseInt((d[0] / 1024 / 1024).toFixed(2)); }); // we calculate the labels based on timePeriod of chart / datapoints var minuteSteps = timePeriod / datapoints.length; var labels = datapoints.map(function (d, index) { var dateTime = new Date(Date.now() - ((timePeriod - (index * minuteSteps)) * 60 * 1000)); if ($scope.graphs.period > 24) { return dateTime.toLocaleDateString(); } else { return dateTime.toLocaleTimeString(); } }); var graphData = { labels: labels, datasets: [{ label: label, backgroundColor: '#82C4F844', borderColor: '#2196F3', borderWidth: 1, radius: 0, data: datapoints }] }; var options = { maintainAspectRatio: true, aspectRatio: 2.5, legend: { display: false }, tooltips: { intersect: false }, scales: { xAxes: [{ ticks: { autoSkipPadding: 20, } }], yAxes: [{ ticks: { min: 0, max: max, beginAtZero: true } }] } }; var ctx = $(canvasId).get(0).getContext('2d'); if ($scope.graphs[chartPropertyName]) $scope.graphs[chartPropertyName].destroy(); $scope.graphs[chartPropertyName] = new Chart(ctx, { type: 'line', data: graphData, options: options }); } var memoryQuery = 'summarize(sum(collectd.localhost.table-' + appId + '-memory.gauge-rss, collectd.localhost.table-' + appId + '-memory.gauge-swap), "' + timeBucketSize + 'min", "avg")'; Client.graphs([ memoryQuery ], '-' + timePeriod + 'min', { appId: appId }, function (error, result) { if (error) return console.error(error); var currentMemoryLimit = $scope.app.memoryLimit || $scope.app.manifest.memoryLimit || (256 * 1024 * 1024); fillGraph('#graphsMemoryChart', result[0], 'Memory', 'memoryChart', currentMemoryLimit / 1024 / 1024); }); } }; $scope.email = { busy: false, enableMailbox: true, mailboxName: '', mailboxDomain: null, currentMailboxName: '', currentMailboxDomainName: '', mailboxError: {}, enableInbox: true, inboxName: '', inboxDomain: null, currentInboxName: '', currentInboxDomainName: '', inboxError: {}, show: function () { var app = $scope.app; $scope.emailForm.$setPristine(); $scope.email.mailboxError = {}; $scope.email.enableMailbox = app.enableMailbox ? '1' : '0'; $scope.email.mailboxName = app.mailboxName || ''; $scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === (app.mailboxDomain || app.domain); })[0]; $scope.email.currentMailboxName = app.mailboxName || ''; $scope.email.currentMailboxDomainName = $scope.email.mailboxDomain ? $scope.email.mailboxDomain.domain : ''; $scope.inboxForm.$setPristine(); $scope.email.inboxError = {}; $scope.email.enableInbox = app.enableInbox ? '1' : '0'; $scope.email.inboxName = app.inboxName || ''; $scope.email.inboxDomain = $scope.domains.filter(function (d) { return d.domain === (app.inboxDomain || app.domain); })[0]; $scope.email.currentInboxName = app.inboxName || ''; $scope.email.currentInboxDomainName = $scope.email.inboxDomain ? $scope.email.inboxDomain.domain : ''; }, submitMailbox: function () { $scope.email.error = {}; $scope.email.busy = true; var data = { enable: $scope.email.enableMailbox === '1' }; if (data.enable) { data.mailboxName = $scope.email.mailboxName || null; data.mailboxDomain = $scope.email.mailboxDomain.domain; } Client.configureApp($scope.app.id, 'mailbox', data, function (error) { if (error && error.statusCode === 400) { $scope.email.busy = false; $scope.email.error.mailboxName = error.message; $scope.emailForm.$setPristine(); return; } if (error) return Client.error(error); $scope.emailForm.$setPristine(); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); // when the mailboxName is 'reset', this will fill it up with the default again $scope.email.enableMailbox = $scope.app.enableMailbox ? '1' : '0'; $scope.email.mailboxName = $scope.app.mailboxName || ''; $scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === ($scope.app.mailboxDomain || $scope.app.domain); })[0]; $scope.email.currentMailboxName = $scope.app.mailboxName || ''; $scope.email.currentMailboxDomainName = $scope.email.mailboxDomain ? $scope.email.mailboxDomain.domain : ''; $timeout(function () { $scope.email.busy = false; }, 1000); }); }); }, submitInbox: function () { $scope.email.error = {}; $scope.email.busy = true; var data = { enable: $scope.email.enableInbox === '1' }; if (data.enable) { data.inboxName = $scope.email.inboxName; data.inboxDomain = $scope.email.inboxDomain.domain; } Client.configureApp($scope.app.id, 'inbox', data, function (error) { if (error && error.statusCode === 400) { $scope.email.busy = false; $scope.email.error.inboxName = error.message; $scope.inboxForm.$setPristine(); return; } if (error) return Client.error(error); $scope.inboxForm.$setPristine(); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); // when the mailboxName is 'reset', this will fill it up with the default again $scope.email.enableInbox = $scope.app.enableInbox ? '1' : '0'; $scope.email.inboxName = $scope.app.inboxName || ''; $scope.email.inboxDomain = $scope.domains.filter(function (d) { return d.domain === ($scope.app.inboxDomain || $scope.app.domain); })[0]; $scope.email.currentInboxName = $scope.app.inboxName || ''; $scope.email.currentInboxDomainName = $scope.email.inboxDomain ? $scope.email.inboxDomain.domain : ''; $timeout(function () { $scope.email.busy = false; }, 1000); }); }); } }; $scope.eventlog = { busy: false, eventlogs: [], activeEventLog: null, currentPage: 1, perPage: 30, show: function () { $scope.eventlog.refresh(); }, refresh: function () { $scope.eventlog.busy = true; Client.getAppEventLog($scope.app.id, $scope.eventlog.currentPage, $scope.eventlog.perPage, function (error, result) { if (error) return console.error('Failed to get events:', error); $scope.eventlog.eventLogs = []; result.forEach(function (e) { $scope.eventlog.eventLogs.push({ raw: e, details: Client.eventLogDetails(e), source: Client.eventLogSource(e) }); }); $scope.eventlog.busy = false; }); }, showDetails: function (eventLog) { if ($scope.eventlog.activeEventLog === eventLog) $scope.eventlog.activeEventLog = null; else $scope.eventlog.activeEventLog = eventLog; }, showNextPage: function () { $scope.eventlog.currentPage++; $scope.eventlog.refresh(); }, showPrevPage: function () { if ($scope.eventlog.currentPage > 1) $scope.eventlog.currentPage--; else $scope.eventlog.currentPage = 1; $scope.eventlog.refresh(); } }; $scope.cron = { busy: false, error: {}, commonPatterns: [ { value: '* * * * *', label: $translate.instant('app.cron.commonPattern.everyMinute') }, { value: '0 * * * *', label: $translate.instant('app.cron.commonPattern.everyHour') }, { value: '*/30 * * * *', label: $translate.instant('app.cron.commonPattern.twicePerHour') }, { value: '0 0 * * *', label: $translate.instant('app.cron.commonPattern.everyDay') }, { value: '0 */12 * * *', label: $translate.instant('app.cron.commonPattern.twicePerDay') }, { value: '0 0 * * 0', label: $translate.instant('app.cron.commonPattern.everySunday') } ], crontab: '', crontabDefault: '' + '# +------------------------ minute (0 - 59)\n' + '# | +------------------- hour (0 - 23)\n' + '# | | +-------------- day of month (1 - 31)\n' + '# | | | +--------- month (1 - 12)\n' + '# | | | | +---- day of week (0 - 6) (Sunday=0 or 7)\n' + '# | | | | |\n' + '# * * * * * command to be executed\n\n', show: function () { $scope.cronForm.$setPristine(); $scope.cron.error = {}; $scope.cron.crontab = $scope.app.crontab; if ($scope.cron.crontab === null) $scope.cron.crontab = $scope.cron.crontabDefault; // only when null, not when '' }, submit: function () { $scope.cron.error = {}; $scope.cron.busy = true; Client.configureApp($scope.app.id, 'crontab', { crontab: $scope.cron.crontab }, function (error) { if (error && error.statusCode === 400) { $scope.cron.busy = false; $scope.cron.error.crontab = error.message; $scope.cronForm.$setPristine(); return; } if (error) return Client.error(error); $scope.cronForm.$setPristine(); $timeout(function () { $scope.cron.busy = false; }, 1000); }); }, addCommonPattern: function (pattern) { $scope.cron.crontab += ' ' + pattern + ' command to be executed\n'; } }; $scope.security = { busy: false, error: {}, success: false, robotsTxt: '', csp: '', show: function () { $scope.security.error = {}; $scope.security.robotsTxt = $scope.app.reverseProxyConfig.robotsTxt || ''; $scope.security.csp = $scope.app.reverseProxyConfig.csp || ''; }, submit: function () { $scope.security.busy = true; $scope.security.error = {}; var reverseProxyConfig = { robotsTxt: $scope.security.robotsTxt || null, // empty string resets csp: $scope.security.csp || null // empty string resets }; Client.configureApp($scope.app.id, 'reverse_proxy', reverseProxyConfig, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.security.success = true; $scope.security.busy = false; }, 1000); }); } }; $scope.updates = { busy: false, busyCheck: false, busyUpdate: false, busyAutomaticUpdates: false, skipBackup: false, enableAutomaticUpdate: false, show: function () { var app = $scope.app; $scope.updates.enableAutomaticUpdate = app.enableAutomaticUpdate; $scope.updates.skipBackup = !app.enableAutomaticUpdate && !app.enableBackup; }, toggleAutomaticUpdates: function () { $scope.updates.busyAutomaticUpdates = true; Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.updates.enableAutomaticUpdate = !$scope.updates.enableAutomaticUpdate; $scope.updates.busyAutomaticUpdates = false; }, 1000); }); }, check: function () { $scope.updates.busyCheck = true; Client.checkForAppUpdates($scope.app.id, function (error) { if (error) Client.error(error); $scope.updates.busyCheck = false; }); }, askUpdate: function () { $scope.updates.busyUpdate = false; $('#updateModal').modal('show'); }, confirmUpdate: function () { $scope.updates.busyUpdate = true; Client.updateApp($scope.app.id, $scope.config.update[$scope.app.id].manifest, { skipBackup: $scope.updates.skipBackup }, function (error) { $scope.updates.busyUpdate = false; if (error) return Client.error(error); $('#updateModal').modal('hide'); refreshApp($scope.app.id); }); } }; $scope.backups = { busy: false, busyCreate: false, busyAutomaticBackups: false, error: {}, enableBackup: false, backups: [], createBackup: function () { $scope.backups.busyCreate = true; Client.backupApp($scope.app.id, function (error) { if (error) Client.error(error); refreshApp($scope.app.id, function () { $scope.backups.busyCreate = false; waitForAppTask(function (error) { if (error) return Client.error(error); $scope.backups.show(); // refresh backup listing }); }); }); }, show: function () { var app = $scope.app; $scope.backups.error = {}; $scope.backups.enableBackup = app.enableBackup; Client.getAppBackups(app.id, function (error, backups) { if (error) return Client.error(error); $scope.backups.backups = backups; Client.getAppEventLog(app.id, 1, 1, function (error, result) { if (error) return console.error('Failed to get events:', error); if (result.length !== 0 && result[0].action == 'app.backup.finish') { $scope.backups.error.message = result[0].data.errorMessage; } }); }); }, toggleAutomaticBackups: function () { $scope.backups.busyAutomaticBackups = true; $scope.backups.error = {}; Client.configureApp($scope.app.id, 'automatic_backup', { enable: !$scope.backups.enableBackup }, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.backups.enableBackup = !$scope.backups.enableBackup; $scope.backups.busyAutomaticBackups = false; }, 1000); }); } }; $scope.s3like = function (provider) { return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces' || provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage'; }; $scope.mountlike = function (provider) { return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs'; }; $scope.importBackup = { busy: false, error: {}, // variables here have to match the import config logic! provider: '', bucket: '', prefix: '', mountPoint: '', accessKeyId: '', secretAccessKey: '', gcsKey: { keyFileName: '', content: '' }, region: '', endpoint: '', acceptSelfSignedCerts: false, format: 'tgz', backupId: '', password: '', mountOptions: {}, clearForm: function () { // $scope.importBackup.provider = ''; // do not clear since we call this function on provider change $scope.importBackup.bucket = ''; $scope.importBackup.prefix = ''; $scope.importBackup.mountPoint = ''; $scope.importBackup.accessKeyId = ''; $scope.importBackup.secretAccessKey = ''; $scope.importBackup.gcsKey.keyFileName = ''; $scope.importBackup.gcsKey.content = ''; $scope.importBackup.endpoint = ''; $scope.importBackup.region = ''; $scope.importBackup.format = 'tgz'; $scope.importBackup.acceptSelfSignedCerts = false; $scope.importBackup.password = ''; $scope.importBackup.backupId = ''; $scope.importBackup.mountOptions = {}; }, submit: function () { $scope.importBackup.error = {}; $scope.importBackup.busy = true; var backupConfig = { provider: $scope.importBackup.provider, }; if ($scope.importBackup.password) backupConfig.password = $scope.importBackup.password; var backupId = $scope.importBackup.backupId; // 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.accessKeyId = $scope.importBackup.accessKeyId; backupConfig.secretAccessKey = $scope.importBackup.secretAccessKey; if ($scope.importBackup.endpoint) backupConfig.endpoint = $scope.importBackup.endpoint; if (backupConfig.provider === 's3') { if ($scope.importBackup.region) backupConfig.region = $scope.importBackup.region; delete backupConfig.endpoint; } else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') { backupConfig.region = backupConfig.region || 'us-east-1'; backupConfig.acceptSelfSignedCerts = $scope.importBackup.acceptSelfSignedCerts; backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI } else if (backupConfig.provider === 'exoscale-sos') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'wasabi') { backupConfig.region = $scope.wasabiRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'scaleway-objectstorage') { backupConfig.region = $scope.scalewayRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'linode-objectstorage') { backupConfig.region = $scope.linodeRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'ovh-objectstorage') { backupConfig.region = $scope.ovhRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'ionos-objectstorage') { backupConfig.region = $scope.ionosRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'vultr-objectstorage') { backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'upcloud-objectstorage') { var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint); backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'digitalocean-spaces') { backupConfig.region = 'us-east-1'; } } else if (backupConfig.provider === 'gcs') { backupConfig.bucket = $scope.importBackup.bucket; backupConfig.prefix = $scope.importBackup.prefix; try { var serviceAccountKey = JSON.parse($scope.importBackup.gcsKey.content); backupConfig.projectId = serviceAccountKey.project_id; backupConfig.credentials = { client_email: serviceAccountKey.client_email, private_key: serviceAccountKey.private_key }; if (!backupConfig.projectId || !backupConfig.credentials || !backupConfig.credentials.client_email || !backupConfig.credentials.private_key) { throw 'fields_missing'; } } catch (e) { $scope.importBackup.error.generic = 'Cannot parse Google Service Account Key: ' + e.message; $scope.importBackup.error.gcsKeyInput = true; $scope.importBackup.busy = false; return; } } else if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs') { backupConfig.mountOptions = $scope.importBackup.mountOptions; backupConfig.prefix = $scope.importBackup.prefix; } else if (backupConfig.provider === 'filesystem') { var parts = backupId.split('/'); backupId = parts.pop() || parts.pop(); // removes any trailing slash. this is basename() backupConfig.backupFolder = parts.join('/'); // this is dirname() } if ($scope.importBackup.format === 'tgz') { if (backupId.substring(backupId.length - '.tar.gz'.length, backupId.length) === '.tar.gz') { // endsWith backupId = backupId.replace(/.tar.gz$/, ''); } else if (backupId.substring(backupId.length - '.tar.gz.enc'.length, backupId.length) === '.tar.gz.enc') { // endsWith backupId = backupId.replace(/.tar.gz.enc$/, ''); } } Client.importBackup($scope.app.id, backupId, $scope.importBackup.format, backupConfig, function (error) { if (error) { $scope.importBackup.busy = false; if (error.statusCode === 424) { $scope.importBackup.error.generic = error.message; if (error.message.indexOf('AWS Access Key Id') !== -1) { $scope.importBackup.error.accessKeyId = true; $scope.importBackupForm.accessKeyId.$setPristine(); $('#inputImportBackupAccessKeyId').focus(); } else if (error.message.indexOf('not match the signature') !== -1 || error.message.indexOf('Signature') !== -1) { $scope.importBackup.error.secretAccessKey = true; $scope.importBackupForm.secretAccessKey.$setPristine(); $('#inputImportBackupSecretAccessKey').focus(); } else if (error.message.toLowerCase() === 'access denied') { $scope.importBackup.error.accessKeyId = true; $scope.importBackupForm.accessKeyId.$setPristine(); $('#inputImportBackupBucket').focus(); } else if (error.message.indexOf('ECONNREFUSED') !== -1) { $scope.importBackup.error.generic = 'Unknown region'; $scope.importBackup.error.region = true; $scope.importBackupForm.region.$setPristine(); $('#inputImportBackupDORegion').focus(); } else if (error.message.toLowerCase() === 'wrong region') { $scope.importBackup.error.generic = 'Wrong S3 Region'; $scope.importBackup.error.region = true; $scope.importBackupForm.region.$setPristine(); $('#inputImportBackupS3Region').focus(); } else { $scope.importBackup.error.bucket = true; $('#inputImportBackupBucket').focus(); $scope.importBackupForm.bucket.$setPristine(); } } else if (error.statusCode === 400) { $scope.importBackup.error.generic = error.message; if ($scope.importBackup.provider === 'filesystem') { $scope.importBackup.error.backupFolder = true; } } else { Client.error(error); } return; } $('#importBackupModal').modal('hide'); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.importBackup.busy = false; }, 1000); }); }); }, show: function () { $scope.importBackup.clearForm(); $('#importBackupModal').modal('show'); }, }; $scope.uninstall = { busy: false, error: {}, busyRunState: false, startButton: false, toggleRunState: function (confirmStop) { if (confirmStop && $scope.app.runState !== RSTATES.STOPPED) { $('#stopModal').modal('show'); return; } $('#stopModal').modal('hide'); var func = $scope.app.runState === RSTATES.STOPPED ? Client.startApp : Client.stopApp; $scope.uninstall.busyRunState = true; func($scope.app.id, function (error) { if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.uninstall.busyRunState = false; }, 1000); }); }); }, show: function () { $scope.uninstall.error = {}; }, ask: function () { $('#uninstallModal').modal('show'); }, submit: function () { $scope.uninstall.busy = true; var NOOP = function (next) { return next(); }; var stopAppTask = $scope.app.taskId ? Client.stopTask.bind(null, $scope.app.taskId) : NOOP; stopAppTask(function () { // ignore error Client.uninstallApp($scope.app.id, function (error) { if (error && error.statusCode === 402) { // unpurchase failed Client.error('Relogin to Cloudron App Store'); } else if (error) { Client.error(error); } else { $('#uninstallModal').modal('hide'); $location.path('/apps'); } $scope.uninstall.busy = false; }); }); } }; $scope.restore = { busy: false, error: {}, backup: null, show: function (backup) { $scope.restore.error = {}; $scope.restore.backup = backup; $('#restoreModal').modal('show'); }, submit: function () { $scope.restore.busy = true; Client.restoreApp($scope.app.id, $scope.restore.backup.id, function (error) { if (error) { Client.error(error); $scope.restore.busy = false; return; } $('#restoreModal').modal('hide'); refreshApp($scope.app.id); }); } }; $scope.clone = { busy: false, error: {}, backup: null, location: '', domain: null, portBindings: {}, portBindingsInfo: {}, portBindingsEnabled: {}, show: function (backup) { var app = $scope.app; $scope.clone.error = {}; $scope.clone.backup = backup; $scope.clone.domain = $scope.domains.find(function (d) { return app.domain === d.domain; }); // pre-select the app's domain $scope.clone.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information // set default ports for (var env in $scope.clone.portBindingsInfo) { $scope.clone.portBindings[env] = $scope.clone.portBindingsInfo[env].defaultValue || 0; $scope.clone.portBindingsEnabled[env] = true; } $('#appCloneModal').modal('show'); }, submit: function () { $scope.clone.busy = true; // 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]; } } var data = { location: $scope.clone.location, domain: $scope.clone.domain.domain, portBindings: finalPortBindings, backupId: $scope.clone.backup.id }; Client.checkDNSRecords(data.domain, data.location, function (error, result) { if (error) { Client.error(error); $scope.clone.busy = false; return; } if (result.error) { if (result.error.reason === ERROR.ACCESS_DENIED) { $scope.clone.error.location = 'DNS credentials for ' + data.domain + ' are invalid. Update it in Domains & Certs view'; } else { $scope.clone.error.location = result.error.message; } $scope.clone.needsOverwrite = true; $scope.clone.busy = false; return; } if (result.needsOverwrite) { $scope.clone.error.location = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron'; $scope.clone.needsOverwrite = true; $scope.clone.busy = false; return; } Client.cloneApp($scope.app.id, data, function (error/*, clonedApp */) { $scope.clone.busy = false; if (error) { if (error.statusCode === 409) { if (error.portName) { $scope.clone.error.port = error.message; } else if (error.domain) { $scope.clone.error.location = 'This location is already taken.'; $('#cloneLocationInput').focus(); } else { Client.error(error); } } else { Client.error(error); } return; } $('#appCloneModal').modal('hide'); $location.path('/apps'); }); }); } }; $scope.repair = { retryBusy: false, error: {}, location: null, domain: null, alternateDomains: [], aliasDomains: [], backups: [], backupId: '', show: function () {}, // this prepares the repair dialog with whatever is required for repair action confirm: function () { $scope.repair.error = {}; $scope.repair.retryBusy = false; $scope.repair.location = null; $scope.repair.domain = null; $scope.repair.alternateDomains = []; $scope.repair.aliasDomains = []; $scope.repair.backupId = ''; var app = $scope.app; var errorState = ($scope.app.error && $scope.app.error.installationState) || ISTATES.PENDING_CONFIGURE; if (errorState === ISTATES.PENDING_LOCATION_CHANGE) { $scope.repair.location = app.location; $scope.repair.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0]; $scope.repair.aliasDomains = $scope.app.aliasDomains; $scope.repair.aliasDomains = $scope.app.aliasDomains.map(function (aliasDomain) { return { subdomain: aliasDomain.subdomain, enabled: true, domain: $scope.domains.filter(function (d) { return d.domain === aliasDomain.domain; })[0] }; }); $scope.repair.alternateDomains = $scope.app.alternateDomains; $scope.repair.alternateDomains = $scope.app.alternateDomains.map(function (altDomain) { return { subdomain: altDomain.subdomain, enabled: true, domain: $scope.domains.filter(function (d) { return d.domain === altDomain.domain; })[0] }; }); } if (errorState === ISTATES.PENDING_RESTORE || errorState === ISTATES.PENDING_IMPORT) { Client.getAppBackups($scope.app.id, function (error, backups) { if (error) return Client.error(error); $scope.repair.backups = backups; $scope.repair.backupId = ''; $('#repairModal').modal('show'); }); return; } $('#repairModal').modal('show'); }, submit: function () { $scope.repair.error = {}; $scope.repair.retryBusy = true; var errorState = ($scope.app.error && $scope.app.error.installationState) || ISTATES.PENDING_CONFIGURE; var data = {}; var repairFunc; switch (errorState) { case ISTATES.PENDING_INSTALL: case ISTATES.PENDING_CLONE: // if manifest or bad image, use CLI to provide new manifest repairFunc = Client.repairApp.bind(null, $scope.app.id, {}); // this will trigger a re-install break; case ISTATES.PENDING_LOCATION_CHANGE: data.location = $scope.repair.location; data.domain = $scope.repair.domain.domain; data.aliasDomains = $scope.repair.aliasDomains.filter(function (a) { return a.enabled; }) .map(function (d) { return { subdomain: d.subdomain, domain: d.domain.domain }; }); data.alternateDomains = $scope.repair.alternateDomains.filter(function (a) { return a.enabled; }) .map(function (d) { return { subdomain: d.subdomain, domain: d.domain.domain }; }); data.overwriteDns = true; // always overwriteDns. user can anyway check and uncheck above repairFunc = Client.configureApp.bind(null, $scope.app.id, 'location', data); break; case ISTATES.PENDING_DATA_DIR_MIGRATION: repairFunc = Client.configureApp.bind(null, $scope.app.id, 'data_dir', { dataDir: null }); break; // this also happens for import faliures. this UI can only show backup listing. use CLI for arbit id/config case ISTATES.PENDING_RESTORE: case ISTATES.PENDING_IMPORT: if ($scope.repair.backups.length === 0) { // this can happen when you give some invalid backup via CLI and restore via UI repairFunc = Client.repairApp.bind(null, $scope.app.id, {}); // this will trigger a re-install } else { repairFunc = Client.restoreApp.bind(null, $scope.app.id, $scope.repair.backupId); } break; case ISTATES.PENDING_UNINSTALL: repairFunc = Client.uninstallApp.bind(null, $scope.app.id); break; case ISTATES.PENDING_START: case ISTATES.PENDING_STOP: case ISTATES.PENDING_RESTART: case ISTATES.PENDING_RESIZE: case ISTATES.PENDING_DEBUG: case ISTATES.PENDING_RECREATE_CONTAINER: case ISTATES.PENDING_CONFIGURE: case ISTATES.PENDING_BACKUP: // can happen if the backup task was killed/rebooted case ISTATES.PENDING_UPDATE: // when update failed, just bring it back to current state and user can click update again default: repairFunc = Client.repairApp.bind(null, $scope.app.id, {}); break; } repairFunc(function (error) { $scope.repair.retryBusy = false; if (error) return Client.error(error); $scope.repair.retryBusy = false; $('#repairModal').modal('hide'); }); }, restartBusy: false, restartApp: function () { $scope.repair.restartBusy = true; Client.restartApp($scope.app.id, function (error) { if (error) return console.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.repair.restartBusy = false; }, 1000); }); }); }, pauseBusy: false, pauseAppBegin: function () { $scope.repair.pauseBusy = true; Client.debugApp($scope.app.id, true, function (error) { if (error) return console.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.repair.pauseBusy = false; }, 1000); }); }); }, pauseAppDone: function () { $scope.repair.pauseBusy = true; Client.debugApp($scope.app.id, false, function (error) { if (error) return console.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.repair.pauseBusy = false; }, 1000); }); }); } }; function fetchUsers(callback) { Client.getUsers(function (error, users) { if (error) return callback(error); // ensure we have something to work with in the access restriction dropdowns users.forEach(function (user) { user.display = user.username || user.email; }); $scope.users = users; callback(); }); } function fetchGroups(callback) { Client.getGroups(function (error, groups) { if (error) return callback(error); $scope.groups = groups; callback(); }); } function getDomains(callback) { Client.getDomains(function (error, result) { if (error) return callback(error); $scope.domains = result; callback(); }); } function getVolumes(callback) { Client.getVolumes(function (error, result) { if (error) return callback(error); $scope.volumes = result; callback(); }); } function getBackupConfig(callback) { Client.getBackupConfig(function (error, backupConfig) { if (error) return callback(error); $scope.backupConfig = backupConfig; callback(); }); } function refreshApp(appId, callback) { callback = callback || function () {}; Client.getAppWithTask(appId, function (error, app) { if (error && error.statusCode === 404) return $location.path('/apps'); if (error) return callback(error); $scope.app = app; // show 'Start App' if app is starting or is stopped if (app.installationState === ISTATES.PENDING_START || app.installationState === ISTATES.PENDING_STOP) { $scope.uninstall.startButton = app.installationState === ISTATES.PENDING_START; } else { $scope.uninstall.startButton = app.runState === RSTATES.STOPPED; } callback(); }); } function waitForAppTask(callback) { callback = callback || function () {}; if (!$scope.app.taskId) return callback(); // app will be refreshed on interval $timeout(waitForAppTask.bind(null, callback), 2000); // not yet done } // https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server#18197341 function download(filename, text) { var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } $scope.downloadConfig = function (backup) { // secrets and tokens already come with placeholder characters we remove them var tmp = { backupId: backup.id, encrypted: !!$scope.backupConfig.password // we add this just to help the import UI }; Object.keys($scope.backupConfig).forEach(function (k) { if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = $scope.backupConfig[k]; }); var filename = 'app-backup-config-' + (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + ' (' + $scope.app.fqdn + ')' + '.json'; download(filename, JSON.stringify(tmp)); }; document.getElementById('backupConfigFileInput').onchange = function (event) { var reader = new FileReader(); reader.onload = function (result) { if (!result.target || !result.target.result) return console.error('Unable to read backup config'); var backupConfig; try { backupConfig = JSON.parse(result.target.result); if (backupConfig.provider === 'filesystem') { // patch the backupId to have the full path backupConfig.backupId = backupConfig.backupFolder + '/' + backupConfig.backupId; delete backupConfig.backupFolder; } } catch (e) { console.error('Unable to parse backup config'); return; } $scope.$apply(function () { // we assume property names match here, this does not yet work for gcs keys Object.keys(backupConfig).forEach(function (k) { if (k in $scope.importBackup) { $scope.importBackup[k] = backupConfig[k]; } }); }); }; reader.readAsText(event.target.files[0]); }; Client.onReady(function () { refreshApp(appId, function (error) { if (error) return Client.error(error); if ($scope.app.accessLevel !== 'admin' && $scope.app.accessLevel !== 'operator') return $location.path('/'); // skipViewShow because we don't have all the values like domains/users to init the view yet if ($routeParams.view) { // explicit route in url bar $scope.setView($routeParams.view, true /* skipViewShow */); } else { // default $scope.setView($scope.app.error ? 'repair' : 'display', true /* skipViewShow */); } function done() { $scope[$scope.view].show(); // initialize now that we have all the values var refreshTimer = $interval(function () { refreshApp($scope.app.id); }, 5000); // call with inline function to avoid iteration argument passed see $interval docs $scope.$on('$destroy', function () { $interval.cancel(refreshTimer); }); } if ($scope.app.accessLevel !== 'admin') return done(); async.series([ fetchUsers, fetchGroups, getDomains, getVolumes, getBackupConfig ], function (error) { if (error) return Client.error(error); done(); }); }); }); $('#iconFileInput').get(0).onchange = function (event) { var fr = new FileReader(); fr.onload = function () { $scope.$apply(function () { // var file = event.target.files[0]; $scope.display.icon.data = fr.result; }); }; fr.readAsDataURL(event.target.files[0]); }; // setup all the dialog focus handling ['appUninstallModal', 'appUpdateModal', 'appRestoreModal', 'appCloneModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find('[autofocus]:first').focus(); }); }); var clipboard = new Clipboard('.clipboard'); clipboard.on('success', function () { $scope.$apply(function () { $scope.copyBackupIdDone = true; }); $timeout(function () { $scope.copyBackupIdDone = false; }, 5000); }); $('.modal-backdrop').remove(); }]);