2390 lines
95 KiB
JavaScript
2390 lines
95 KiB
JavaScript
'use strict';
|
|
|
|
/* global angular */
|
|
/* global $ */
|
|
/* global async */
|
|
/* global RSTATES */
|
|
/* global ISTATES */
|
|
/* global ERROR */
|
|
/* global Chart */
|
|
/* global Clipboard */
|
|
/* global SECRET_PLACEHOLDER */
|
|
/* global APP_TYPES, 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_HETZNER */
|
|
/* global onAppClick */
|
|
|
|
angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) {
|
|
$scope.s3Regions = REGIONS_S3;
|
|
$scope.wasabiRegions = REGIONS_WASABI;
|
|
$scope.doSpacesRegions = REGIONS_DIGITALOCEAN;
|
|
$scope.exoscaleSosRegions = REGIONS_EXOSCALE;
|
|
$scope.scalewayRegions = REGIONS_SCALEWAY;
|
|
$scope.linodeRegions = REGIONS_LINODE;
|
|
$scope.ovhRegions = REGIONS_OVH;
|
|
$scope.ionosRegions = REGIONS_IONOS;
|
|
$scope.upcloudRegions = REGIONS_UPCLOUD;
|
|
$scope.vultrRegions = REGIONS_VULTR;
|
|
$scope.contaboRegions = REGIONS_VULTR;
|
|
$scope.hetznerRegions = REGIONS_HETZNER;
|
|
|
|
$scope.storageProviders = STORAGE_PROVIDERS;
|
|
|
|
$scope.formats = BACKUP_FORMATS;
|
|
|
|
// 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.diskUsage = -1;
|
|
$scope.diskUsageDate = 0;
|
|
|
|
$scope.APP_TYPES = APP_TYPES;
|
|
$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.appPostInstallConfirm = {
|
|
app: {},
|
|
message: '',
|
|
|
|
show: function (app) {
|
|
$scope.appPostInstallConfirm.app = app;
|
|
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
|
|
|
|
$('#appPostInstallConfirmModal').modal('show');
|
|
|
|
return false; // prevent propagation and default
|
|
},
|
|
|
|
submit: function () {
|
|
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
|
|
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
|
|
|
|
$('#appPostInstallConfirmModal').modal('hide');
|
|
}
|
|
};
|
|
|
|
$scope.postInstallMessage = {
|
|
openApp: false,
|
|
|
|
show: function (openApp) {
|
|
$scope.postInstallMessage.openApp = !!openApp;
|
|
|
|
if (!$scope.app.manifest.postInstallMessage) return;
|
|
$('#postInstallModal').modal('show');
|
|
},
|
|
|
|
submit: function () {
|
|
$scope.app.pendingPostInstallConfirmation = false;
|
|
delete localStorage['confirmPostInstall_' + $scope.app.id];
|
|
|
|
$('#postInstallModal').modal('hide');
|
|
}
|
|
};
|
|
|
|
$scope.getAppBackupDownloadLink = function (backup) {
|
|
return Client.getAppBackupDownloadLink($scope.app.id, backup.id);
|
|
};
|
|
|
|
$scope.onAppClick = function (app, $event) { onAppClick(app, $event, true /* always operator */, $scope); };
|
|
|
|
$scope.sftpInfo = {
|
|
show: function () {
|
|
$('#sftpInfoModal').modal('show');
|
|
}
|
|
};
|
|
|
|
$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: {},
|
|
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, // object and not the string
|
|
subdomain: '',
|
|
secondaryDomains: {},
|
|
redirectDomains: [],
|
|
aliasDomains: [],
|
|
ports: {},
|
|
portsEnabled: {},
|
|
portInfo: {},
|
|
|
|
addRedirectDomain: function (event) {
|
|
event.preventDefault();
|
|
$scope.location.redirectDomains.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('redirectDomainsInput-' + ($scope.location.redirectDomains.length-1)).focus();
|
|
}, 200);
|
|
},
|
|
|
|
delRedirectDomain: function (event, index) {
|
|
event.preventDefault();
|
|
$scope.location.redirectDomains.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.subdomain = app.subdomain;
|
|
$scope.location.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0];
|
|
|
|
// for compat, secondary domain can be empty after an upgrade. so it may not exist in app.secondaryDomains
|
|
$scope.location.secondaryDomains = {};
|
|
var httpPorts = app.manifest.httpPorts || {};
|
|
for (var env2 in httpPorts) {
|
|
$scope.location.secondaryDomains[env2] = {
|
|
subdomain: httpPorts[env2].defaultValue || '',
|
|
domain: $scope.location.domain
|
|
};
|
|
}
|
|
|
|
// now fill secondaryDomains with real values, if it exists
|
|
app.secondaryDomains.forEach(function (sd) {
|
|
$scope.location.secondaryDomains[sd.environmentVariable] = {
|
|
subdomain: sd.subdomain,
|
|
domain: $scope.domains.filter(function (d) { return d.domain === sd.domain; })[0]
|
|
};
|
|
});
|
|
|
|
$scope.location.portInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
|
|
$scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
|
|
$scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
|
|
|
|
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
|
|
for (var env in $scope.location.portInfo) {
|
|
if (app.portBindings && app.portBindings[env]) {
|
|
$scope.location.ports[env] = app.portBindings[env].hostPort;
|
|
$scope.location.portsEnabled[env] = true;
|
|
} else {
|
|
$scope.location.ports[env] = $scope.location.portInfo[env].defaultValue || 0;
|
|
$scope.location.portsEnabled[env] = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
submit: function (overwriteDns) {
|
|
$('#domainCollisionsModal').modal('hide');
|
|
|
|
$scope.location.busy = true;
|
|
$scope.location.error = {};
|
|
$scope.location.domainCollisions = [];
|
|
|
|
var secondaryDomains = {};
|
|
for (var env2 in $scope.location.secondaryDomains) {
|
|
secondaryDomains[env2] = {
|
|
subdomain: $scope.location.secondaryDomains[env2].subdomain,
|
|
domain: $scope.location.secondaryDomains[env2].domain.domain
|
|
};
|
|
}
|
|
|
|
// only use enabled ports
|
|
var ports = {};
|
|
for (var env in $scope.location.ports) {
|
|
if ($scope.location.portsEnabled[env]) {
|
|
ports[env] = $scope.location.ports[env];
|
|
}
|
|
}
|
|
|
|
var data = {
|
|
overwriteDns: !!overwriteDns,
|
|
subdomain: $scope.location.subdomain,
|
|
domain: $scope.location.domain.domain,
|
|
ports: ports,
|
|
secondaryDomains: secondaryDomains,
|
|
redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
|
|
aliasDomains: $scope.location.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };})
|
|
};
|
|
|
|
// pre-flight only for changed domains
|
|
var domains = [];
|
|
if ($scope.app.domain !== data.domain || $scope.app.subdomain !== data.subdomain) domains.push({ subdomain: data.subdomain, domain: data.domain, type: 'primary' });
|
|
Object.keys(data.secondaryDomains).forEach(function (env) {
|
|
var subdomain = data.secondaryDomains[env].subdomain, domain = data.secondaryDomains[env].domain;
|
|
if ($scope.app.secondaryDomains.some(function (d) { return d.domain === domain && d.subdomain === subdomain; })) return;
|
|
domains.push({ subdomain: subdomain, domain: domain, type: 'secondary' });
|
|
});
|
|
data.redirectDomains.forEach(function (a) {
|
|
if ($scope.app.redirectDomains.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' });
|
|
});
|
|
|
|
var canConfigure = true;
|
|
|
|
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 === 'primary') {
|
|
$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.redirectDomains = domain.domain + ' ' + result.error.message;
|
|
}
|
|
$scope.location.busy = false;
|
|
canConfigure = false;
|
|
} else if (result.needsOverwrite) {
|
|
$scope.location.domainCollisions.push(domain);
|
|
canConfigure = false;
|
|
}
|
|
|
|
callback();
|
|
});
|
|
}, function (error) {
|
|
if (error) {
|
|
$scope.location.busy = false;
|
|
return Client.error(error);
|
|
}
|
|
|
|
if (!canConfigure) {
|
|
$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)) {
|
|
var errorMessage = error.message.toLowerCase();
|
|
if (errorMessage.indexOf('location') !== -1) {
|
|
if (errorMessage.indexOf('primary') !== -1) {
|
|
$scope.location.error.location = error.message;
|
|
$scope.locationForm.$setPristine();
|
|
} else if (errorMessage.indexOf('secondary') !== -1) {
|
|
$scope.location.error.secondaryDomain = error.message;
|
|
} else if (errorMessage.indexOf('redirect') !== -1) {
|
|
$scope.location.error.redirectDomains = error.message;
|
|
} else if (errorMessage.indexOf('alias') !== -1) {
|
|
$scope.location.error.aliasDomains = error.message;
|
|
}
|
|
} else if (errorMessage.indexOf('port') !== -1) {
|
|
$scope.location.error.port = error.message;
|
|
} else {
|
|
$scope.location.error.location = error.message; // fallback
|
|
}
|
|
|
|
$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['oidc'] || 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;
|
|
$timeout(function () {
|
|
$scope.access.success = true;
|
|
$scope.access.busy = false;
|
|
}, 3000);
|
|
});
|
|
}
|
|
};
|
|
|
|
$scope.resources = {
|
|
error: {},
|
|
|
|
busy: false,
|
|
currentMemoryLimit: 0,
|
|
memoryLimit: 0, // RAM
|
|
memoryTicks: [],
|
|
|
|
currentCpuQuota: 0,
|
|
cpuQuota: 0,
|
|
devices: '',
|
|
|
|
show: function () {
|
|
var app = $scope.app;
|
|
$scope.resources.busy = true;
|
|
|
|
$scope.resources.error = {};
|
|
$scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024);
|
|
|
|
Client.memory(function (error, result) {
|
|
if (error) console.error(error);
|
|
|
|
// create ticks starting from manifest memory limit. the memory limit here is just RAM
|
|
$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);
|
|
}
|
|
$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);
|
|
|
|
$scope.resources.devices = Object.keys(app.devices).join(', ');
|
|
},
|
|
|
|
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) {
|
|
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);
|
|
});
|
|
});
|
|
},
|
|
|
|
submitCpuQuota: function () {
|
|
$scope.resources.busy = true;
|
|
$scope.resources.error = {};
|
|
|
|
Client.configureApp($scope.app.id, 'cpu_quota', { cpuQuota: parseInt($scope.resources.cpuQuota) }, function (error) {
|
|
if (error) return Client.error(error);
|
|
|
|
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota;
|
|
|
|
refreshApp($scope.app.id, function (error) {
|
|
if (error) return Client.error(error);
|
|
|
|
$timeout(function () { $scope.resources.busy = false; }, 1000);
|
|
});
|
|
});
|
|
},
|
|
|
|
submitDevices: function () {
|
|
$scope.resources.busy = true;
|
|
$scope.resources.error = {};
|
|
|
|
const devices = {};
|
|
$scope.resources.devices.split(',').forEach(d => {
|
|
if (!d.trim()) return;
|
|
devices[d.trim()] = {};
|
|
});
|
|
|
|
Client.configureApp($scope.app.id, 'devices', { devices }, function (error) {
|
|
if (error && error.statusCode === 400) {
|
|
$scope.resources.error.devices = error.message;
|
|
return $scope.resources.busy = false;
|
|
} else if (error) {
|
|
return Client.error(error);
|
|
}
|
|
|
|
refreshApp($scope.app.id, function (error) {
|
|
if (error) return Client.error(error);
|
|
|
|
$timeout(function () { $scope.resources.busy = false; }, 1000);
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
$scope.services = {
|
|
error: {},
|
|
|
|
busy: false,
|
|
enableTurn: '1', // curse of radio buttons
|
|
enableRedis: '1',
|
|
|
|
show: function () {
|
|
var app = $scope.app;
|
|
|
|
$scope.services.error = {};
|
|
$scope.services.enableTurn = app.enableTurn ? '1' : '0';
|
|
$scope.services.enableRedis = app.enableRedis ? '1' : '0';
|
|
},
|
|
|
|
submitTurn: function () {
|
|
$scope.services.busy = true;
|
|
$scope.services.error = {};
|
|
|
|
Client.configureApp($scope.app.id, 'turn', { enable: $scope.services.enableTurn === '1' }, function (error) {
|
|
if (error && error.statusCode === 400) {
|
|
$scope.services.busy = false;
|
|
$scope.services.error.turn = true;
|
|
return;
|
|
}
|
|
if (error) return Client.error(error);
|
|
|
|
$timeout(function () { $scope.services.busy = false; }, 1000);
|
|
});
|
|
},
|
|
|
|
submitRedis: function () {
|
|
$scope.services.busy = true;
|
|
$scope.services.error = {};
|
|
|
|
Client.configureApp($scope.app.id, 'redis', { enable: $scope.services.enableRedis === '1' }, function (error) {
|
|
if (error && error.statusCode === 400) {
|
|
$scope.services.busy = false;
|
|
$scope.services.error.redis = true;
|
|
return;
|
|
}
|
|
if (error) return Client.error(error);
|
|
|
|
$timeout(function () { $scope.services.busy = false; }, 1000);
|
|
});
|
|
},
|
|
};
|
|
|
|
$scope.storage = {
|
|
error: {},
|
|
|
|
busy: false,
|
|
|
|
busyDataDir: false,
|
|
storageVolumeId: null,
|
|
storageVolumePrefix: '',
|
|
|
|
location: null,
|
|
locationOptions: [],
|
|
|
|
busyBinds: false,
|
|
mounts: [], // { volume, readOnly }
|
|
|
|
show: function () {
|
|
var app = $scope.app;
|
|
|
|
$scope.storage.error = {};
|
|
$scope.storage.storageVolumeId = app.storageVolumeId;
|
|
$scope.storage.storageVolumePrefix = app.storageVolumePrefix || '';
|
|
$scope.storage.mounts = [];
|
|
|
|
$scope.storage.locationOptions = [
|
|
{ id: 'default', type: 'default', displayName: 'Default - /home/yellowtent/appsdata/' + app.id },
|
|
];
|
|
|
|
$scope.volumes.forEach(function (volume) {
|
|
$scope.storage.locationOptions.push({ id: volume.id, type: 'volume', value: volume.id, displayName: 'Volume - ' + volume.name, mountType: volume.mountType });
|
|
});
|
|
|
|
$scope.storage.location = $scope.storage.locationOptions.find(function (l) { return l.id === (app.storageVolumeId || 'default'); });
|
|
|
|
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 ? 'true' : 'false' });
|
|
});
|
|
},
|
|
|
|
submitDataDir: function () {
|
|
$scope.storage.busyDataDir = true;
|
|
$scope.storage.error = {};
|
|
|
|
var data = { storageVolumeId: null, storageVolumePrefix: null };
|
|
if ($scope.storage.location.id !== 'default') {
|
|
data.storageVolumeId = $scope.storage.location.id;
|
|
data.storageVolumePrefix = $scope.storage.storageVolumePrefix;
|
|
}
|
|
|
|
Client.configureApp($scope.app.id, 'storage', data, function (error) {
|
|
if (error && error.statusCode === 400) {
|
|
$scope.storage.error.storageVolumePrefix = error.message;
|
|
$scope.storage.busyDataDir = false;
|
|
return;
|
|
} else if (error) {
|
|
Client.error(error);
|
|
$scope.storage.busyDataDir = false;
|
|
return;
|
|
}
|
|
|
|
$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 === 'true' });
|
|
});
|
|
|
|
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: {},
|
|
busy: true,
|
|
|
|
period: 6,
|
|
memoryChart: null,
|
|
diskChart: null,
|
|
|
|
blockReadTotal: 0,
|
|
blockWriteTotal: 0,
|
|
networkReadTotal: 0,
|
|
networkWriteTotal: 0,
|
|
|
|
setPeriod: function (hours) {
|
|
$scope.graphs.period = hours;
|
|
$scope.graphs.show();
|
|
},
|
|
|
|
show: function () {
|
|
$scope.graphs.busy = true;
|
|
// in minutes
|
|
var timePeriod = $scope.graphs.period * 60;
|
|
|
|
// keep in sync with graphs.js
|
|
var timeBucketSizeMinutes = timePeriod > (24 * 60) ? (6*60) : 5;
|
|
var steps = Math.floor(timePeriod/timeBucketSizeMinutes);
|
|
|
|
var labels = new Array(steps).fill(0);
|
|
labels = labels.map(function (v, index) {
|
|
var dateTime = new Date(Date.now() - ((timePeriod - (index * timeBucketSizeMinutes)) * 60 * 1000));
|
|
|
|
if ($scope.graphs.period > 24) {
|
|
return dateTime.toLocaleDateString();
|
|
} else {
|
|
return dateTime.toLocaleTimeString();
|
|
}
|
|
});
|
|
|
|
var borderColors = [ '#2196F3', '#FF6384' ];
|
|
var backgroundColors = [ '#82C4F844', '#FF63844F' ];
|
|
|
|
function fillGraph(canvasId, contents, chartPropertyName, divisor, max, format, formatDivisor, stepSize) {
|
|
if (!contents || !contents[0]) return; // no data available yet
|
|
|
|
var datasets = [];
|
|
|
|
contents.forEach(function (content, index) {
|
|
|
|
// fill holes with previous value
|
|
var cur = 0;
|
|
content.data.forEach(function (d) {
|
|
if (d[0] === null) d[0] = cur;
|
|
else cur = d[0];
|
|
});
|
|
|
|
var datapoints = Array(steps).map(function () { return '0'; });
|
|
|
|
// walk backwards and fill up the datapoints
|
|
content.data.reverse().forEach(function (d, index) {
|
|
datapoints[datapoints.length-1-index] = (d[0] / divisor).toFixed(2);
|
|
// return parseInt((d[0] / divisor).toFixed(2));
|
|
});
|
|
|
|
datasets.push({
|
|
label: content.label,
|
|
backgroundColor: backgroundColors[index],
|
|
borderColor: borderColors[index],
|
|
borderWidth: 1,
|
|
radius: 0,
|
|
data: datapoints,
|
|
cubicInterpolationMode: 'monotone',
|
|
tension: 0.4
|
|
});
|
|
});
|
|
|
|
var graphData = {
|
|
labels: labels,
|
|
datasets: datasets
|
|
};
|
|
|
|
var options = {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
aspectRatio: 2.5,
|
|
animation: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
},
|
|
interaction: {
|
|
intersect: false,
|
|
mode: 'index',
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { autoSkipPadding: 50, maxRotation: 0 }
|
|
},
|
|
y: {
|
|
ticks: { maxTicksLimit: 6 },
|
|
min: 0,
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
};
|
|
|
|
if (format) options.scales.y.ticks.callback = function (value) {
|
|
if (!formatDivisor) return value + ' ' + format;
|
|
return (value/formatDivisor).toLocaleString('en-US', { maximumFractionDigits: 6 }) + ' ' + format;
|
|
};
|
|
if (max) options.scales.y.max = max;
|
|
if (stepSize) options.scales.y.ticks.stepSize = stepSize;
|
|
|
|
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 });
|
|
}
|
|
|
|
Client.getAppGraphs(appId, timePeriod, function (error, result) {
|
|
if (error) return console.error(error);
|
|
|
|
var currentMemoryLimit = $scope.app.memoryLimit || $scope.app.manifest.memoryLimit || 0;
|
|
var maxGraphMemory = currentMemoryLimit < (512 * 1024 * 1024) ? (512 * 1024 * 1024) : currentMemoryLimit;
|
|
var cpuCount = result.cpuCount;
|
|
var ioDivisor = 1000 * 1000;
|
|
|
|
$scope.graphs.blockReadTotal = (result.blockReadTotal / ioDivisor / 1000).toFixed(2) + ' MB';
|
|
$scope.graphs.blockWriteTotal = (result.blockWriteTotal / ioDivisor / 1000).toFixed(2) + ' MB';
|
|
$scope.graphs.networkReadTotal = (result.networkReadTotal / ioDivisor / 1000).toFixed(2) + ' MB';
|
|
$scope.graphs.networkWriteTotal = (result.networkWriteTotal / ioDivisor / 1000).toFixed(2) + ' MB';
|
|
|
|
fillGraph('#graphsMemoryChart', [{ data: result.memory, label: 'Memory' }], 'memoryChart', 1024 * 1024, maxGraphMemory / 1024 / 1024, 'GiB', 1024, (maxGraphMemory / 1024 / 1024) <= 1024 ? 256 : 512);
|
|
fillGraph('#graphsCpuChart', [{ data: result.cpu, label: 'CPU' }], 'cpuChart', 1, cpuCount * 100, '%');
|
|
fillGraph('#graphsDiskChart', [{ data: result.blockRead, label: 'read' }, { data: result.blockWrite, label: 'write' }], 'diskChart', ioDivisor, null, 'kB/s');
|
|
fillGraph('#graphsNetworkChart', [{ data: result.networkRead, label: 'inbound' }, { data: result.networkWrite, label: 'outbound' }], 'networkChart', ioDivisor, null, 'kB/s');
|
|
|
|
$scope.graphs.busy = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
function findInbox(inboxes, app) {
|
|
return inboxes.find(function (i) { return i.name === app.inboxName && i.domain === (app.inboxDomain || app.domain); });
|
|
}
|
|
|
|
$scope.email = {
|
|
enableMailbox: true,
|
|
mailboxName: '',
|
|
mailboxDomain: null,
|
|
mailboxDisplayName: '',
|
|
currentMailboxName: '',
|
|
currentMailboxDomainName: '',
|
|
mailboxError: {},
|
|
mailboxBusy: false,
|
|
|
|
inboxError: {},
|
|
inboxBusy: false,
|
|
enableInbox: true,
|
|
inboxes: [],
|
|
currentInbox: null,
|
|
inbox: null,
|
|
|
|
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.mailboxDisplayName = app.mailboxDisplayName || '';
|
|
$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.email.inboxError = {};
|
|
$scope.email.enableInbox = app.enableInbox ? true : false;
|
|
|
|
Client.getAllMailboxes(function (error, mailboxes) {
|
|
if (error) console.error('Failed to list mailboxes.', error);
|
|
|
|
$scope.email.inboxes = mailboxes.map(function (m) { return { display: m.name + '@' + m.domain, name: m.name, domain: m.domain }; });
|
|
$scope.email.currentInbox = findInbox($scope.email.inboxes, app);
|
|
$scope.email.inbox = findInbox($scope.email.inboxes, app);
|
|
});
|
|
},
|
|
|
|
submitMailbox: function () {
|
|
$scope.email.error = {};
|
|
$scope.email.mailboxBusy = true;
|
|
|
|
var data = {
|
|
enable: $scope.email.enableMailbox === '1'
|
|
};
|
|
|
|
if (data.enable) {
|
|
data.mailboxName = $scope.email.mailboxName || null;
|
|
data.mailboxDomain = $scope.email.mailboxDomain.domain;
|
|
data.mailboxDisplayName = $scope.email.mailboxDisplayName;
|
|
}
|
|
|
|
Client.configureApp($scope.app.id, 'mailbox', data, function (error) {
|
|
if (error && error.statusCode === 400) {
|
|
$scope.email.mailboxBusy = 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.mailboxBusy = false; }, 1000);
|
|
});
|
|
});
|
|
},
|
|
|
|
submitInbox: function () {
|
|
$scope.email.error = {};
|
|
$scope.email.inboxBusy = true;
|
|
|
|
var data = {
|
|
enable: $scope.email.enableInbox
|
|
};
|
|
|
|
if (data.enable) {
|
|
data.inboxName = $scope.email.inbox.name;
|
|
data.inboxDomain = $scope.email.inbox.domain;
|
|
}
|
|
|
|
Client.configureApp($scope.app.id, 'inbox', data, function (error) {
|
|
if (error && error.statusCode === 400) {
|
|
$scope.email.inboxBusy = false;
|
|
$scope.email.error.inboxName = error.message;
|
|
return;
|
|
}
|
|
if (error) return Client.error(error);
|
|
|
|
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 ? true : false;
|
|
$scope.email.currentInbox = findInbox($scope.email.inboxes, $scope.app);
|
|
$scope.email.inbox = findInbox($scope.email.inboxes, $scope.app);
|
|
|
|
$timeout(function () { $scope.email.inboxBusy = false; }, 1000);
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
$scope.eventlog = {
|
|
busy: false,
|
|
eventLogs: [],
|
|
activeEventLog: null,
|
|
currentPage: 1,
|
|
perPage: 15,
|
|
|
|
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, $scope.app.id), 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') },
|
|
|
|
{ value: '@daily', label: $translate.instant('app.cron.commonPattern.daily') },
|
|
{ value: '@hourly', label: $translate.instant('app.cron.commonPattern.hourly') },
|
|
{ value: '@service', label: $translate.instant('app.cron.commonPattern.service') }
|
|
],
|
|
|
|
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 + ' /path/to/command\n';
|
|
}
|
|
};
|
|
|
|
$scope.security = {
|
|
busy: false,
|
|
error: {},
|
|
success: false,
|
|
|
|
robotsTxt: '',
|
|
csp: '',
|
|
hstsPreload: false,
|
|
|
|
show: function () {
|
|
$scope.security.error = {};
|
|
$scope.security.robotsTxt = $scope.app.reverseProxyConfig.robotsTxt || '';
|
|
$scope.security.csp = $scope.app.reverseProxyConfig.csp || '';
|
|
$scope.security.hstsPreload = $scope.app.reverseProxyConfig.hstsPreload || false;
|
|
},
|
|
|
|
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
|
|
hstsPreload: $scope.security.hstsPreload
|
|
};
|
|
|
|
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.proxy = {
|
|
busy: false,
|
|
error: null,
|
|
success: false,
|
|
|
|
upstreamUri: '',
|
|
|
|
show: function () {
|
|
$scope.proxyForm.$setPristine();
|
|
$scope.proxy.error = null;
|
|
$scope.proxy.upstreamUri = $scope.app.upstreamUri || '';
|
|
},
|
|
|
|
submit: function () {
|
|
$scope.proxy.busy = true;
|
|
$scope.proxy.error = null;
|
|
|
|
var upstreamUri = $scope.proxy.upstreamUri.replace(/\/$/, '');
|
|
Client.configureApp($scope.app.id, 'upstream_uri', { upstreamUri: upstreamUri }, function (error) {
|
|
$scope.proxy.busy = false;
|
|
|
|
if (error && error.statusCode === 400) {
|
|
$scope.proxy.error = error.message;
|
|
$scope.proxyForm.$setPristine();
|
|
return;
|
|
}
|
|
if (error) return Client.error(error);
|
|
|
|
$scope.proxyForm.$setPristine();
|
|
|
|
$timeout(function () {
|
|
$scope.proxy.success = true;
|
|
}, 1000);
|
|
});
|
|
}
|
|
};
|
|
|
|
$scope.updates = {
|
|
busy: false,
|
|
busyCheck: false,
|
|
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) {
|
|
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);
|
|
});
|
|
});
|
|
},
|
|
|
|
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.backupDetails = {
|
|
backup: null,
|
|
|
|
show: function (backup) {
|
|
$scope.backupDetails.backup = backup;
|
|
$('#backupDetailsModal').modal('show');
|
|
}
|
|
};
|
|
|
|
$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;
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
refresh: function () {
|
|
Client.getAppBackups($scope.app.id, function (error, backups) {
|
|
if (error) return Client.error(error);
|
|
|
|
$scope.backups.backups = backups;
|
|
});
|
|
},
|
|
|
|
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 === 'hetzner-objectstorage'
|
|
|| provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'cloudflare-r2'
|
|
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|
|
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2'
|
|
|| provider === 'contabo-objectstorage';
|
|
};
|
|
|
|
$scope.mountlike = function (provider) {
|
|
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs';
|
|
};
|
|
|
|
$scope.importBackup = {
|
|
busy: false,
|
|
error: {},
|
|
|
|
// variables here have to match the import config logic!
|
|
provider: '',
|
|
bucket: '',
|
|
prefix: '',
|
|
mountPoint: '', // for mountpoint
|
|
accessKeyId: '',
|
|
secretAccessKey: '',
|
|
gcsKey: { keyFileName: '', content: '' },
|
|
region: '',
|
|
endpoint: '',
|
|
acceptSelfSignedCerts: false,
|
|
format: 'tgz',
|
|
remotePath: '',
|
|
password: '',
|
|
encryptedFilenames: true,
|
|
mountOptions: {
|
|
host: '',
|
|
remoteDir: '',
|
|
username: '',
|
|
password: '',
|
|
diskPath: '',
|
|
user: '',
|
|
seal: true,
|
|
port: 22,
|
|
privateKey: ''
|
|
},
|
|
encrypted: false, // helps with ng-required when backupConfig is read from file
|
|
|
|
clearForm: function () {
|
|
// $scope.importBackup.provider = ''; // do not clear since we call this function on provider change
|
|
$scope.importBackup.bucket = '';
|
|
$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.encryptedFilenames = true;
|
|
$scope.importBackup.remotePath = '';
|
|
$scope.importBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
|
|
},
|
|
|
|
submit: function () {
|
|
$scope.importBackup.error = {};
|
|
$scope.importBackup.busy = true;
|
|
|
|
var backupConfig = {
|
|
provider: $scope.importBackup.provider,
|
|
};
|
|
if ($scope.importBackup.password) {
|
|
backupConfig.password = $scope.importBackup.password;
|
|
backupConfig.encryptedFilenames = $scope.importBackup.encryptedFilenames;
|
|
}
|
|
|
|
var remotePath = $scope.importBackup.remotePath;
|
|
|
|
// 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 === 'contabo-objectstorage') {
|
|
backupConfig.region = $scope.contaboRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region;
|
|
backupConfig.signatureVersion = 'v4';
|
|
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
|
} 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 === 'hetzner-objectstorage') {
|
|
backupConfig.region = 'us-east-1';
|
|
backupConfig.signatureVersion = 'v4';
|
|
}
|
|
} 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.provider === 'ext4' || backupConfig.provider === 'xfs') {
|
|
backupConfig.mountOptions = $scope.importBackup.mountOptions;
|
|
backupConfig.prefix = $scope.importBackup.prefix;
|
|
} else if (backupConfig.provider === 'mountpoint') {
|
|
backupConfig.prefix = $scope.importBackup.prefix;
|
|
backupConfig.mountPoint = $scope.importBackup.mountPoint;
|
|
} else if (backupConfig.provider === 'filesystem') {
|
|
var parts = remotePath.split('/');
|
|
remotePath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
|
|
backupConfig.backupFolder = parts.join('/'); // this is dirname()
|
|
}
|
|
|
|
if ($scope.importBackup.format === 'tgz') {
|
|
if (remotePath.substring(remotePath.length - '.tar.gz'.length, remotePath.length) === '.tar.gz') { // endsWith
|
|
remotePath = remotePath.replace(/.tar.gz$/, '');
|
|
} else if (remotePath.substring(remotePath.length - '.tar.gz.enc'.length, remotePath.length) === '.tar.gz.enc') { // endsWith
|
|
remotePath = remotePath.replace(/.tar.gz.enc$/, '');
|
|
}
|
|
}
|
|
|
|
Client.importBackup($scope.app.id, remotePath, $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');
|
|
|
|
// clear potential post-install flag
|
|
$scope.app.pendingPostInstallConfirmation = false;
|
|
delete localStorage['confirmPostInstall_' + $scope.app.id];
|
|
|
|
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.editBackup = {
|
|
busy: false,
|
|
error: null,
|
|
backup: null,
|
|
|
|
label: '',
|
|
persist: false,
|
|
|
|
show: function (backup) {
|
|
$scope.editBackup.backup = backup;
|
|
$scope.editBackup.label = backup.label;
|
|
$scope.editBackup.persist = backup.preserveSecs === -1;
|
|
$scope.editBackup.error = null;
|
|
$scope.editBackup.busy = false;
|
|
|
|
$('#editBackupModal').modal('show');
|
|
},
|
|
|
|
submit: function () {
|
|
$scope.editBackup.error = null;
|
|
$scope.editBackup.busy = true;
|
|
|
|
Client.editAppBackup($scope.app.id, $scope.editBackup.backup.id, $scope.editBackup.label, $scope.editBackup.persist ? -1 : 0, function (error) {
|
|
$scope.editBackup.busy = false;
|
|
if (error) return $scope.editBackup.error = error.message;
|
|
|
|
$scope.backups.refresh();
|
|
|
|
$('#editBackupModal').modal('hide');
|
|
});
|
|
}
|
|
};
|
|
|
|
$scope.backupDetails = {
|
|
backup: null,
|
|
|
|
show: function (backup) {
|
|
$scope.backupDetails.backup = backup;
|
|
$('#backupDetailsModal').modal('show');
|
|
}
|
|
};
|
|
|
|
$scope.uninstall = {
|
|
busy: false,
|
|
error: {},
|
|
busyRunState: false,
|
|
startButton: false,
|
|
latestBackup: null,
|
|
|
|
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 = {};
|
|
|
|
$scope.uninstall.latestBackup = null;
|
|
|
|
Client.getAppBackups($scope.app.id, function (error, backups) {
|
|
// only backups with appConfig (post 8.2) are candidates for archive
|
|
if (!error && backups.length) $scope.uninstall.latestBackup = backups[0].appConfig ? backups[0] : null;
|
|
});
|
|
},
|
|
|
|
ask: function (what) {
|
|
if (what === 'uninstall') {
|
|
$('#uninstallModal').modal('show');
|
|
} else {
|
|
$('#archiveModal').modal('show');
|
|
}
|
|
},
|
|
|
|
submit: function (what) {
|
|
$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
|
|
const func = what === 'uninstall' ?
|
|
Client.uninstallApp.bind(null, $scope.app.id) :
|
|
Client.archiveApp.bind(Client, $scope.app.id, $scope.uninstall.latestBackup.id);
|
|
|
|
func(function (error) {
|
|
if (error && error.statusCode === 402) { // unpurchase failed
|
|
Client.error('Relogin to Cloudron App Store');
|
|
} else if (error) {
|
|
Client.error(error);
|
|
} else {
|
|
if (what === 'uninstall') {
|
|
$('#uninstallModal').modal('hide');
|
|
} else {
|
|
$('#archiveModal').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) {
|
|
$scope.restore.busy = false;
|
|
|
|
if (error) {
|
|
Client.error(error);
|
|
return;
|
|
}
|
|
|
|
$('#restoreModal').modal('hide');
|
|
|
|
refreshApp($scope.app.id);
|
|
});
|
|
}
|
|
};
|
|
|
|
$scope.clone = {
|
|
busy: false,
|
|
error: {},
|
|
|
|
backup: null,
|
|
subdomain: '',
|
|
domain: null,
|
|
secondaryDomains: {},
|
|
needsOverwrite: false,
|
|
overwriteDns: false,
|
|
ports: {},
|
|
portsEnabled: {},
|
|
portInfo: {},
|
|
|
|
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.needsOverwrite = false;
|
|
$scope.clone.overwriteDns = false;
|
|
|
|
$scope.clone.secondaryDomains = {};
|
|
|
|
var httpPorts = backup.manifest.httpPorts || {};
|
|
for (var env2 in httpPorts) {
|
|
$scope.clone.secondaryDomains[env2] = {
|
|
subdomain: httpPorts[env2].defaultValue || '',
|
|
domain: $scope.clone.domain
|
|
};
|
|
}
|
|
|
|
$scope.clone.portInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
|
|
// set default ports
|
|
for (var env in $scope.clone.portInfo) {
|
|
$scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0;
|
|
$scope.clone.portsEnabled[env] = true;
|
|
}
|
|
|
|
$('#appCloneModal').modal('show');
|
|
},
|
|
|
|
submit: function () {
|
|
$scope.clone.busy = true;
|
|
|
|
var secondaryDomains = {};
|
|
for (var env2 in $scope.clone.secondaryDomains) {
|
|
secondaryDomains[env2] = {
|
|
subdomain: $scope.clone.secondaryDomains[env2].subdomain,
|
|
domain: $scope.clone.secondaryDomains[env2].domain.domain
|
|
};
|
|
}
|
|
|
|
// only use enabled ports
|
|
var finalPorts = {};
|
|
for (var env in $scope.clone.ports) {
|
|
if ($scope.clone.portsEnabled[env]) {
|
|
finalPorts[env] = $scope.clone.ports[env];
|
|
}
|
|
}
|
|
|
|
var data = {
|
|
subdomain: $scope.clone.subdomain,
|
|
domain: $scope.clone.domain.domain,
|
|
secondaryDomains: secondaryDomains,
|
|
ports: finalPorts,
|
|
backupId: $scope.clone.backup.id,
|
|
overwriteDns: $scope.clone.overwriteDns
|
|
};
|
|
|
|
var allDomains = [{ domain: data.domain, subdomain: data.subdomain }].concat(Object.keys(secondaryDomains).map(function (k) {
|
|
return {
|
|
domain: secondaryDomains[k].domain,
|
|
subdomain: secondaryDomains[k].subdomain
|
|
};
|
|
}));
|
|
async.eachSeries(allDomains, function (domain, callback) {
|
|
if ($scope.clone.overwriteDns) return callback();
|
|
|
|
Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) {
|
|
if (error) return callback(error);
|
|
|
|
var fqdn = domain.subdomain + '.' + domain.domain;
|
|
|
|
if (result.error) {
|
|
if (result.error.reason === ERROR.ACCESS_DENIED) return callback({ type: 'provider', fqdn: fqdn, message: 'DNS credentials for ' + domain.domain + ' are invalid. Update it in Domains & Certs view' });
|
|
return callback({ type: 'provider', fqdn: fqdn, message: result.error.message });
|
|
}
|
|
if (result.needsOverwrite) {
|
|
$scope.clone.needsOverwrite = true;
|
|
$scope.clone.overwriteDns = true;
|
|
return callback({ type: 'externally_exists', fqdn: fqdn, message: 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron' });
|
|
}
|
|
|
|
callback();
|
|
});
|
|
}, function (error) {
|
|
if (error) {
|
|
if (error.type) {
|
|
$scope.clone.error.location = error;
|
|
$scope.clone.busy = false;
|
|
} else {
|
|
Client.error(error);
|
|
}
|
|
|
|
$scope.clone.error.location = error;
|
|
$scope.clone.busy = false;
|
|
return;
|
|
}
|
|
|
|
Client.cloneApp($scope.app.id, data, function (error/*, clonedApp */) {
|
|
$scope.clone.busy = false;
|
|
|
|
if (error) {
|
|
var errorMessage = error.message.toLowerCase();
|
|
if (errorMessage.indexOf('port') !== -1) {
|
|
$scope.clone.error.port = error.message;
|
|
} else if (error.message.indexOf('location') !== -1 || error.message.indexOf('subdomain') !== -1) {
|
|
// TODO extract fqdn from error message, currently we just set it always to the main location
|
|
$scope.clone.error.location = { type: 'internally_exists', fqdn: data.subdomain + '.' + data.domain, message: error.message };
|
|
$('#cloneLocationInput').focus();
|
|
} else {
|
|
Client.error(error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
$('#appCloneModal').modal('hide');
|
|
|
|
$location.path('/apps');
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
$scope.repair = {
|
|
retryBusy: false,
|
|
error: {},
|
|
|
|
subdomain: null,
|
|
domain: null,
|
|
redirectDomains: [],
|
|
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.subdomain = null;
|
|
$scope.repair.domain = null;
|
|
$scope.repair.redirectDomains = [];
|
|
$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.subdomain = app.subdomain;
|
|
$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.redirectDomains = $scope.app.redirectDomains;
|
|
$scope.repair.redirectDomains = $scope.app.redirectDomains.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.subdomain = $scope.repair.subdomain;
|
|
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.redirectDomains = $scope.repair.redirectDomains.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, 'storage', { storageVolumeId: null, storageVolumePrefix: 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.getAllUsers(function (error, users) {
|
|
if (error) return callback(error);
|
|
|
|
$scope.users = users;
|
|
|
|
callback();
|
|
});
|
|
}
|
|
|
|
function fetchGroups(callback) {
|
|
Client.getGroups(function (error, groups) {
|
|
if (error) return callback(error);
|
|
|
|
$scope.groups = groups;
|
|
|
|
callback();
|
|
});
|
|
}
|
|
|
|
function fetchDiskUsage(callback) {
|
|
$scope.diskUsage = -1;
|
|
$scope.diskUsageDate = 0;
|
|
|
|
Client.diskUsage(function (error, result) {
|
|
if (error) return callback(error);
|
|
if (!result.usage) return callback(); // no usage date yet
|
|
|
|
$scope.diskUsageDate = result.usage.ts;
|
|
|
|
for (var diskName in result.usage.disks) {
|
|
var disk = result.usage.disks[diskName];
|
|
var content = disk.contents.find(function (c) { return c.id === appId; });
|
|
|
|
if (content) {
|
|
$scope.diskUsage = content.usage;
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 = {
|
|
remotePath: backup.remotePath,
|
|
encrypted: !!$scope.backupConfig.password // we add this just to help the import UI
|
|
};
|
|
|
|
Object.keys($scope.backupConfig).forEach(function (k) {
|
|
var v = $scope.backupConfig[k];
|
|
if (v && typeof v === 'object') { // to hide mountOptions.password and the likes
|
|
tmp[k] = {};
|
|
Object.keys(v).forEach(function (j) {
|
|
if (v[j] !== SECRET_PLACEHOLDER) tmp[k][j] = v[j];
|
|
});
|
|
} else {
|
|
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = v;
|
|
}
|
|
});
|
|
|
|
const filename = `${$scope.app.fqdn}-backup-config-${(new Date(backup.creationTime)).toISOString().split('T')[0]}.json`;
|
|
download(filename, JSON.stringify(tmp, null, 4));
|
|
};
|
|
|
|
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') { // this allows a user to upload a backup to server and import easily with an absolute path
|
|
backupConfig.remotePath = backupConfig.backupFolder + '/' + backupConfig.remotePath;
|
|
delete backupConfig.backupFolder;
|
|
}
|
|
} catch (e) {
|
|
console.error('Unable to parse backup config', e);
|
|
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' : 'info', 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,
|
|
fetchDiskUsage,
|
|
getDomains,
|
|
getVolumes,
|
|
getBackupConfig
|
|
], function (error) {
|
|
if (error) return Client.error(error);
|
|
|
|
// check for updates, if the app has a pending update. this handles two cases:
|
|
// 1. user got a valid subscription. this will make the updates get the manifest field
|
|
// 2. user has not refreshed the ui in a while or updated via cli tool. this will ensure we are not holding to a dangling update
|
|
if ($scope.config.update[$scope.app.id]) Client.checkForUpdates();
|
|
|
|
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', 'editBackupModal'].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();
|
|
}]);
|