diff --git a/src/index.html b/src/index.html index 7e3aede98..6848325be 100644 --- a/src/index.html +++ b/src/index.html @@ -158,6 +158,7 @@
  • Account
  • Activity
  • API Access
  • +
  • Backups
  • Domains
  • Email
  • Graphs
  • diff --git a/src/js/index.js b/src/js/index.js index 885d4b4fa..4df3a58df 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -66,6 +66,9 @@ app.config(['$routeProvider', function ($routeProvider) { }).when('/account', { controller: 'AccountController', templateUrl: 'views/account.html?<%= revision %>' + }).when('/backups', { + controller: 'BackupsController', + templateUrl: 'views/backups.html?<%= revision %>' }).when('/graphs', { controller: 'GraphsController', templateUrl: 'views/graphs.html?<%= revision %>' diff --git a/src/views/backups.html b/src/views/backups.html new file mode 100644 index 000000000..2289dcc59 --- /dev/null +++ b/src/views/backups.html @@ -0,0 +1,228 @@ + + + + + + +
    + +
    +

    Backups

    +
    + +
    +
    +
    + Provider +
    +
    + {{ prettyProviderName(backupConfig.provider) }} +
    +
    +
    +
    + Location +
    +
    + {{ backupConfig.backupFolder }} + {{ backupConfig.bucket + '/' + backupConfig.prefix }} + {{ backupConfig.region + ' ' + backupConfig.bucket + '/' + backupConfig.prefix }} +
    +
    + +
    +
    + Storage Format +
    +
    + {{ backupConfig.format }} +
    +
    + +
    + +
    +
    + Backup ID +
    +
    + {{ lastBackup.id }} + No backups have been made yet +
    +
    + +
    +
    + Last backup +
    +
    + {{ lastBackup.creationTime | prettyDate }} + - +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    + {{ createBackup.detail || 'Syncing ...' }} +

    +
    +
    + +
    +
    +

    {{ createBackup.message }}

    +

    +

    {{ createBackup.result }}
    +

    +
    +
    + + +
    +
    +
    +
    diff --git a/src/views/backups.js b/src/views/backups.js new file mode 100644 index 000000000..cba1c5561 --- /dev/null +++ b/src/views/backups.js @@ -0,0 +1,376 @@ +'use strict'; + +angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', 'AppStore', function ($scope, $location, $rootScope, $timeout, Client, AppStore) { + Client.onReady(function () { + if (!Client.hasScope('settings')) $location.path('/'); + if (Client.getConfig().provider === 'caas') $location.path('/'); + }); + + $scope.config = Client.getConfig(); + $scope.backupConfig = {}; + $scope.lastBackup = null; + $scope.backups = []; + + // 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 (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: 'EU (Frankfurt)', value: 'eu-central-1' }, + { name: 'EU (Ireland)', value: 'eu-west-1' }, + { name: 'EU (London)', value: 'eu-west-2' }, + { 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.doSpacesRegions = [ + { name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' }, + { name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' }, + { name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' } + ]; + + $scope.storageProvider = [ + { name: 'Amazon S3', value: 's3' }, + { name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' }, + { name: 'Exoscale SOS', value: 'exoscale-sos' }, + { name: 'Filesystem', value: 'filesystem' }, + { name: 'Google Cloud Storage', value: 'gcs' }, + { name: 'Minio', value: 'minio' }, + { name: 'No-op (Only for testing)', value: 'noop' }, + { name: 'S3 API Compatible (v4)', value: 's3-v4-compat' }, + ]; + + $scope.retentionTimes = [ + { name: '2 days', value: 2 * 24 * 60 * 60 }, + { name: '1 week', value: 7 * 24 * 60 * 60}, // the default + { name: '1 month', value: 30 * 24 * 60 * 60}, + { name: 'Forever', value: -1 } + ]; + + $scope.formats = [ + { name: 'Tarball (zipped)', value: 'tgz' }, + { name: 'rsync', value: 'rsync' } + ]; + + $scope.prettyProviderName = function (provider) { + switch (provider) { + case 'caas': return 'Managed Cloudron'; + default: return provider; + } + }; + + $scope.createBackup = { + busy: false, + percent: 0, + message: '', + errorMessage: '', + result: '', + + updateStatus: function () { + Client.progress(function (error, data) { + if (error) return window.setTimeout($scope.createBackup.updateStatus, 250); + + // check if we are done + if (!data.backup || data.backup.percent >= 100) { + if (data.backup && data.backup.message) console.error('Backup message: ' + data.backup.message); // backup error message + + $scope.createBackup.busy = false; + $scope.createBackup.message = ''; + $scope.createBackup.detail = ''; + $scope.createBackup.percent = 100; // indicates that 'result' is valid + $scope.createBackup.result = data.backup ? data.backup.message : null; + + return fetchBackups(); + } + + $scope.createBackup.busy = true; + $scope.createBackup.percent = data.backup.percent; + $scope.createBackup.message = data.backup.message; + $scope.createBackup.detail = data.backup.detail; + window.setTimeout($scope.createBackup.updateStatus, 500); + }); + }, + + doCreateBackup: function () { + $scope.createBackup.busy = true; + $scope.createBackup.percent = 0; + $scope.createBackup.message = ''; + $scope.createBackup.detail = ''; + $scope.createBackup.result = ''; + $scope.createBackup.errorMessage = ''; + + Client.backup(function (error) { + if (error) { + if (error.statusCode === 409 && error.message.indexOf('full_backup') !== -1) { + $scope.createBackup.errorMessage = 'Backup already in progress. Please retry later.'; + } else if (error.statusCode === 409) { + $scope.createBackup.errorMessage = 'App task is currently in progress. Please retry later.'; + } else { + console.error(error); + $scope.createBackup.errorMessage = error.message; + } + + $scope.createBackup.busy = false; + $('#createBackupFailedModal').modal('show'); + + return; + } + + $scope.createBackup.updateStatus(); + }); + } + }; + + $scope.s3like = function (provider) { + return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces'; + }; + + $scope.configureBackup = { + busy: false, + error: {}, + + provider: '', + bucket: '', + prefix: '', + accessKeyId: '', + secretAccessKey: '', + gcsKey: { keyFileName: '', content: '' }, + region: '', + endpoint: '', + backupFolder: '', + retentionSecs: 7 * 24 * 60 * 60, + acceptSelfSignedCerts: false, + useHardlinks: true, + format: 'tgz', + + clearForm: function () { + $scope.configureBackup.bucket = ''; + $scope.configureBackup.prefix = ''; + $scope.configureBackup.accessKeyId = ''; + $scope.configureBackup.secretAccessKey = ''; + $scope.configureBackup.gcsKey.keyFileName = ''; + $scope.configureBackup.gcsKey.content = ''; + $scope.configureBackup.endpoint = ''; + $scope.configureBackup.region = ''; + $scope.configureBackup.backupFolder = ''; + $scope.configureBackup.retentionSecs = 7 * 24 * 60 * 60; + $scope.configureBackup.format = 'tgz'; + $scope.configureBackup.acceptSelfSignedCerts = false; + $scope.configureBackup.useHardlinks = true; + }, + + show: function () { + $scope.configureBackup.error = {}; + $scope.configureBackup.busy = false; + + $scope.configureBackup.provider = $scope.backupConfig.provider; + $scope.configureBackup.bucket = $scope.backupConfig.bucket; + $scope.configureBackup.prefix = $scope.backupConfig.prefix; + $scope.configureBackup.region = $scope.backupConfig.region; + $scope.configureBackup.accessKeyId = $scope.backupConfig.accessKeyId; + $scope.configureBackup.secretAccessKey = $scope.backupConfig.secretAccessKey; + if ($scope.backupConfig.provider === 'gcs') { + $scope.configureBackup.gcsKey.keyFileName = $scope.backupConfig.credentials.client_email; + $scope.configureBackup.gcsKey.content = JSON.stringify({ + project_id: $scope.backupConfig.projectId, + client_email: $scope.backupConfig.credentials.client_email, + private_key: $scope.backupConfig.credentials.private_key, + }); + } + $scope.configureBackup.endpoint = $scope.backupConfig.endpoint; + $scope.configureBackup.key = $scope.backupConfig.key; + $scope.configureBackup.backupFolder = $scope.backupConfig.backupFolder; + $scope.configureBackup.retentionSecs = $scope.backupConfig.retentionSecs; + $scope.configureBackup.format = $scope.backupConfig.format; + $scope.configureBackup.acceptSelfSignedCerts = !!$scope.backupConfig.acceptSelfSignedCerts; + $scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks; + + $('#configureBackupModal').modal('show'); + }, + + submit: function () { + $scope.configureBackup.error = {}; + $scope.configureBackup.busy = true; + + var backupConfig = { + provider: $scope.configureBackup.provider, + key: $scope.configureBackup.key, + retentionSecs: $scope.configureBackup.retentionSecs, + format: $scope.configureBackup.format + }; + + // only set provider specific fields, this will clear them in the db + if ($scope.s3like(backupConfig.provider)) { + backupConfig.bucket = $scope.configureBackup.bucket; + backupConfig.prefix = $scope.configureBackup.prefix; + backupConfig.accessKeyId = $scope.configureBackup.accessKeyId; + backupConfig.secretAccessKey = $scope.configureBackup.secretAccessKey; + + if ($scope.configureBackup.endpoint) backupConfig.endpoint = $scope.configureBackup.endpoint; + + if (backupConfig.provider === 's3') { + if ($scope.configureBackup.region) backupConfig.region = $scope.configureBackup.region; + } else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') { + backupConfig.region = 'us-east-1'; + backupConfig.acceptSelfSignedCerts = $scope.configureBackup.acceptSelfSignedCerts; + } else if (backupConfig.provider === 'exoscale-sos') { + backupConfig.endpoint = 'https://sos-ch-dk-2.exo.io'; + backupConfig.region = 'us-east-1'; + backupConfig.signatureVersion = 'v4'; + } else if (backupConfig.provider === 'digitalocean-spaces') { + backupConfig.region = 'us-east-1'; + } + } else if (backupConfig.provider === 'gcs') { + backupConfig.bucket = $scope.configureBackup.bucket; + backupConfig.prefix = $scope.configureBackup.prefix; + try { + var serviceAccountKey = JSON.parse($scope.configureBackup.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.configureBackup.error.generic = 'Cannot parse Google Service Account Key: ' + e.message; + $scope.configureBackup.error.gcsKeyInput = true; + $scope.configureBackup.busy = false; + return; + } + } else if (backupConfig.provider === 'filesystem') { + backupConfig.backupFolder = $scope.configureBackup.backupFolder; + backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks; + } + + Client.setBackupConfig(backupConfig, function (error) { + $scope.configureBackup.busy = false; + + if (error) { + if (error.statusCode === 402) { + $scope.configureBackup.error.generic = error.message; + + if (error.message.indexOf('AWS Access Key Id') !== -1) { + $scope.configureBackup.error.accessKeyId = true; + $scope.configureBackup.accessKeyId = ''; + $scope.configureBackupForm.accessKeyId.$setPristine(); + $('#inputConfigureBackupAccessKeyId').focus(); + } else if (error.message.indexOf('not match the signature') !== -1 ) { + $scope.configureBackup.error.secretAccessKey = true; + $scope.configureBackup.secretAccessKey = ''; + $scope.configureBackupForm.secretAccessKey.$setPristine(); + $('#inputConfigureBackupSecretAccessKey').focus(); + } else if (error.message.toLowerCase() === 'access denied') { + $scope.configureBackup.error.bucket = true; + $scope.configureBackup.bucket = ''; + $scope.configureBackupForm.bucket.$setPristine(); + $('#inputConfigureBackupBucket').focus(); + } else if (error.message.indexOf('ECONNREFUSED') !== -1) { + $scope.configureBackup.error.generic = 'Unknown region'; + $scope.configureBackup.error.region = true; + $scope.configureBackupForm.region.$setPristine(); + $('#inputConfigureBackupDORegion').focus(); + } else if (error.message.toLowerCase() === 'wrong region') { + $scope.configureBackup.error.generic = 'Wrong S3 Region'; + $scope.configureBackup.error.region = true; + $scope.configureBackupForm.region.$setPristine(); + $('#inputConfigureBackupS3Region').focus(); + } else { + $('#inputConfigureBackupBucket').focus(); + } + } else if (error.statusCode === 400) { + $scope.configureBackup.error.generic = error.message; + + if ($scope.configureBackup.provider === 'filesystem') { + $scope.configureBackup.error.backupFolder = true; + } + } else { + console.error('Unable to change provider.', error); + } + + return; + } + + // $scope.configureBackup.reset(); + $('#configureBackupModal').modal('hide'); + + // now refresh the ui + Client.refreshConfig(); + getBackupConfig(); + }); + } + }; + + function fetchBackups() { + Client.getBackups(function (error, backups) { + if (error) return console.error(error); + + $scope.backups = backups; + + if ($scope.backups.length > 0) { + $scope.lastBackup = backups[0]; + } else { + $scope.lastBackup = null; + } + }); + } + + function getBackupConfig() { + Client.getBackupConfig(function (error, backupConfig) { + if (error) return console.error(error); + + $scope.backupConfig = backupConfig; + + // Check if a proper storage backend is configured. TODO: this check fails if /var/backups is actually external + if (backupConfig.provider === 'filesystem' && backupConfig.backupFolder === '/var/backups') { + var actionScope = $scope.$new(true); + actionScope.action = '/#/settings'; + + Client.notify('Backup Configuration', 'Please setup an external backup storage to avoid data loss', false, 'info', actionScope); + } + }); + } + + Client.onReady(function () { + fetchBackups(); + getBackupConfig(); + + // show backup status + $scope.createBackup.updateStatus(); + }); + + function readFileLocally(obj, file, fileName) { + return function (event) { + $scope.$apply(function () { + obj[file] = null; + obj[fileName] = event.target.files[0].name; + + var reader = new FileReader(); + reader.onload = function (result) { + if (!result.target || !result.target.result) return console.error('Unable to read local file'); + obj[file] = result.target.result; + }; + reader.readAsText(event.target.files[0]); + }); + }; + } + + document.getElementById('gcsKeyFileInput').onchange = readFileLocally($scope.configureBackup.gcsKey, 'content', 'keyFileName'); + + // setup all the dialog focus handling + ['configureBackupModal'].forEach(function (id) { + $('#' + id).on('shown.bs.modal', function () { + $(this).find("[autofocus]:first").focus(); + }); + }); + + $('.modal-backdrop').remove(); +}]);