initial import for Open Source 🎉

This commit is contained in:
Jimmy Zelinskie 2019-11-12 11:09:47 -05:00
parent 1898c361f3
commit 9c0dd3b722
2048 changed files with 218743 additions and 0 deletions

View file

@ -0,0 +1,61 @@
<div ng-if="$ctrl.state === 'choice' && $ctrl.kubeNamespace === false">
<div class="co-dialog modal fade initial-setup-modal in" id="setupModal" style="display: block;">
<div class="modal-backdrop fade in" style="height: 1000px;"></div>
<div class="modal-dialog fade in">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h4 class="modal-title"><span>Choose an option</span></h4>
</div>
<!-- Body -->
<div class="config-setup-wrapper">
<a class="config-setup_option" ng-click="$ctrl.chooseSetup()">
<i class="fas fa-edit fa-2x"></i>
<div>Start New Registry Setup</div>
</a>
<a class="config-setup_option" ng-click="$ctrl.chooseLoad()">
<i class="fas fa-upload fa-2x"></i>
<div>Modify an existing configuration</div>
</a>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
</div>
<div ng-if="$ctrl.state === 'choice' && $ctrl.kubeNamespace !== false">
<div class="co-dialog modal fade initial-setup-modal in" id="kubeSetupModal" style="display: block;">
<div class="modal-backdrop fade in" style="height: 1000px;"></div>
<div class="modal-dialog fade in">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h4 class="modal-title"><span>Choose an option for the <code>{{ $ctrl.kubeNamespace }}</code> namespace</span></h4>
</div>
<!-- Body -->
<div class="config-setup-wrapper">
<a class="config-setup_option" ng-click="$ctrl.chooseSetup()">
<i class="fas fa-edit fa-2x"></i>
<div>Start new configuration for this cluster</div>
</a>
<a class="config-setup_option" ng-click="$ctrl.choosePopulate()">
<i class="fas fa-cloud-download-alt fa-2x"></i>
<div>Modify configuration for this cluster</div>
</a>
<a class="config-setup_option" ng-click="$ctrl.chooseLoad()">
<i class="fas fa-upload fa-2x"></i>
<div>Populate this cluster from a previously created configuration</div>
</a>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
</div>
<div ng-if="$ctrl.state === 'setup'" class="setup" setup-completed="$ctrl.setupCompleted()"></div>
<load-config ng-if="$ctrl.state === 'load'" config-loaded="$ctrl.configLoaded()"></load-config>
<download-tarball-modal
ng-if="$ctrl.state === 'download'"
loaded-config="$ctrl.loadedConfig"
is-kubernetes="$ctrl.kubeNamespace !== false"
choose-deploy="$ctrl.chooseDeploy()">
</download-tarball-modal>
<kube-deploy-modal ng-if="$ctrl.state === 'deploy'" loaded-config="$ctrl.loadedConfig"></kube-deploy-modal>

View file

@ -0,0 +1,70 @@
import { Component, Inject } from 'ng-metadata/core';
const templateUrl = require('./config-setup-app.component.html');
declare var window: any;
/**
* Initial Screen and Choice in the Config App
*/
@Component({
selector: 'config-setup-app',
templateUrl: templateUrl,
})
export class ConfigSetupAppComponent {
private state
: 'choice'
| 'setup'
| 'load'
| 'download'
| 'deploy';
private loadedConfig = false;
private kubeNamespace: string | boolean = false;
constructor(@Inject('ApiService') private apiService) {
this.state = 'choice';
if (window.__kubernetes_namespace) {
this.kubeNamespace = window.__kubernetes_namespace;
}
}
private chooseSetup(): void {
this.apiService.scStartNewConfig()
.then(() => {
this.state = 'setup';
})
.catch(this.apiService.errorDisplay(
'Could not initialize new setup. Please report this error'
));
}
private chooseLoad(): void {
this.state = 'load';
this.loadedConfig = true;
}
private choosePopulate(): void {
this.apiService.scKubePopulateConfig()
.then(() => {
this.state = 'setup';
this.loadedConfig = true;
})
.catch(err => {
this.apiService.errorDisplay(
`Could not populate the configuration from your cluster. Please report this error: ${JSON.stringify(err)}`
)()
})
}
private configLoaded(): void {
this.state = 'setup';
}
private setupCompleted(): void {
this.state = 'download';
}
private chooseDeploy(): void {
this.state = 'deploy';
}
}

View file

@ -0,0 +1,3 @@
<div class="co-floating-bottom-bar">
<span ng-transclude/>
</div>

View file

@ -0,0 +1,44 @@
const templateUrl = require('./cor-floating-bottom-bar.html');
angular.module('quay-config')
.directive('corFloatingBottomBar', function() {
var directiveDefinitionObject = {
priority: 3,
templateUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {},
controller: function($rootScope, $scope, $element, $timeout, $interval) {
var handler = function() {
$element.removeClass('floating');
$element.css('width', $element[0].parentNode.clientWidth + 'px');
var windowHeight = $(window).height();
var rect = $element[0].getBoundingClientRect();
if (rect.bottom > windowHeight) {
$element.addClass('floating');
}
};
$(window).on("scroll", handler);
$(window).on("resize", handler);
var previousHeight = $element[0].parentNode.clientHeight;
var stop = $interval(function() {
var currentHeight = $element[0].parentNode.clientWidth;
if (previousHeight != currentHeight) {
currentHeight = previousHeight;
handler();
}
}, 100);
$scope.$on('$destroy', function() {
$(window).off("resize", handler);
$(window).off("scroll", handler);
$interval.cancel(stop);
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,5 @@
<div class="co-m-inline-loader co-an-fade-in-out">
<div class="co-m-loader-dot__one"></div>
<div class="co-m-loader-dot__two"></div>
<div class="co-m-loader-dot__three"></div>
</div>

View file

@ -0,0 +1,5 @@
<div class="co-m-loader co-an-fade-in-out">
<div class="co-m-loader-dot__one"></div>
<div class="co-m-loader-dot__two"></div>
<div class="co-m-loader-dot__three"></div>
</div>

View file

@ -0,0 +1,28 @@
const loaderUrl = require('./cor-loader.html');
const inlineUrl = require('./cor-loader-inline.html');
angular.module('quay-config')
.directive('corLoader', function() {
var directiveDefinitionObject = {
templateUrl: loaderUrl,
replace: true,
restrict: 'C',
scope: {
},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
})
.directive('corLoaderInline', function() {
var directiveDefinitionObject = {
templateUrl: inlineUrl,
replace: true,
restrict: 'C',
scope: {
},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,3 @@
<li>
<a ng-click="optionClick()" ng-transclude></a>
</li>

View file

@ -0,0 +1,32 @@
const corOption = require('./cor-option.html');
const corOptionsMenu = require('./cor-options-menu.html');
angular.module('quay-config')
.directive('corOptionsMenu', function() {
var directiveDefinitionObject = {
priority: 1,
templateUrl: corOptionsMenu,
replace: true,
transclude: true,
restrict: 'C',
scope: {},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
})
.directive('corOption', function() {
var directiveDefinitionObject = {
priority: 1,
templateUrl: corOption,
replace: true,
transclude: true,
restrict: 'C',
scope: {
'optionClick': '&optionClick'
},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,6 @@
<span class="co-options-menu">
<div class="dropdown" style="text-align: left;">
<i class="fas fa-cog fa-lg dropdown-toggle" data-toggle="dropdown" data-title="Options" bs-tooltip></i>
<ul class="dropdown-menu pull-right" ng-transclude></ul>
</div>
</span>

View file

@ -0,0 +1,4 @@
<div class="cor-progress-bar-element progress">
<div class="progress-bar" ng-style="{'width': (progress * 100) + '%'}"
aria-valuenow="{{ (progress * 100) }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>

View file

@ -0,0 +1,74 @@
const corStepBarUrl = require('./cor-step-bar.html');
const corStepUrl = require('./cor-step.html');
const corProgressBarUrl = require('./cor-progress-bar.html');
angular.module('quay-config')
.directive('corStepBar', () => {
const directiveDefinitionObject = {
priority: 4,
templateUrl: corStepBarUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {
'progress': '=?progress'
},
controller: function($rootScope, $scope, $element) {
$scope.$watch('progress', function(progress) {
if (!progress) { return; }
var index = 0;
for (var i = 0; i < progress.length; ++i) {
if (progress[i]) {
index = i;
}
}
$element.find('.transclude').children('.co-step-element').each(function(i, elem) {
$(elem).removeClass('active');
if (i <= index) {
$(elem).addClass('active');
}
});
});
}
};
return directiveDefinitionObject;
})
.directive('corStep', function() {
var directiveDefinitionObject = {
priority: 4,
templateUrl: corStepUrl,
replace: true,
transclude: false,
requires: '^corStepBar',
restrict: 'C',
scope: {
'icon': '@icon',
'title': '@title',
'text': '@text'
},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
})
.directive('corProgressBar', function() {
var directiveDefinitionObject = {
priority: 4,
templateUrl: corProgressBarUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {
'progress': '=progress'
},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,3 @@
<div class="co-step-bar">
<span class="transclude" ng-transclude/>
</div>

View file

@ -0,0 +1,6 @@
<span ng-class="text ? 'co-step-element text' : 'co-step-element icon'">
<span data-title="{{ title }}" bs-tooltip>
<span class="text" ng-if="text">{{ text }}</span>
<i class="fa fa-lg" ng-if="icon" ng-class="'fa-' + icon"></i>
</span>
</span>

View file

@ -0,0 +1,3 @@
<div class="col-lg-6 col-md-6 col-sm-5 col-xs-12">
<h2 class="co-nav-title-content co-fx-text-shadow" ng-transclude></h2>
</div>

View file

@ -0,0 +1,2 @@
<div class="co-nav-title" ng-transclude></div>

View file

@ -0,0 +1,31 @@
const titleUrl = require('./cor-title.html');
const titleContentUrl = require('./cor-title-content.html');
angular.module('quay-config')
.directive('corTitleContent', function() {
var directiveDefinitionObject = {
priority: 1,
templateUrl: titleContentUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
})
.directive('corTitle', function() {
var directiveDefinitionObject = {
priority: 1,
templateUrl: titleUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,3 @@
<span class="datetime-picker-element">
<input class="form-control" type="text" ng-model="selected_datetime"/>
</span>

View file

@ -0,0 +1,60 @@
const templateUrl = require('./datetime-picker.html');
/**
* An element which displays a datetime picker.
*/
angular.module('quay-config').directive('datetimePicker', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl,
replace: false,
transclude: true,
restrict: 'C',
scope: {
'datetime': '=datetime',
},
controller: function($scope, $element) {
var datetimeSet = false;
$(function() {
$element.find('input').datetimepicker({
'format': 'LLL',
'sideBySide': true,
'showClear': true,
'minDate': new Date(),
'debug': false
});
$element.find('input').on("dp.change", function (e) {
$scope.$apply(function() {
$scope.datetime = e.date ? e.date.unix() : null;
});
});
});
$scope.$watch('selected_datetime', function(value) {
if (!datetimeSet) { return; }
if (!value) {
if ($scope.datetime) {
$scope.datetime = null;
}
return;
}
$scope.datetime = (new Date(value)).getTime()/1000;
});
$scope.$watch('datetime', function(value) {
if (!value) {
$scope.selected_datetime = null;
datetimeSet = true;
return;
}
$scope.selected_datetime = moment.unix(value).format('LLL');
datetimeSet = true;
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,58 @@
<div>
<div class="co-dialog modal fade initial-setup-modal in" id="setupModal" style="display: block;">
<div class="modal-backdrop fade in" style="height: 1000px;"></div>
<div class="modal-dialog fade in">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<span class="cor-step-bar">
<span class="cor-step active" title="Configure Database" text="1"></span>
<span class="cor-step active" title="Setup Database" icon="database"></span>
<span class="cor-step active" title="Create Superuser" text="2"></span>
<span class="cor-step active" title="Configure Registry" text="3"></span>
<span class="cor-step active" title="Validate Configuration" text="4"></span>
<span class="cor-step active" title="Setup Complete" icon="download"></span>
</span>
<h4 class="modal-title"><span>Download Configuration</span></h4>
</div>
<!-- Body -->
<div class="modal-body download-tarball-modal">
<div ng-if="$ctrl.loadedConfig">
Please download your updated configuration. To deploy these changes to your Red Hat Quay instances, please
<a target="_blank" href="https://coreos.com/quay-enterprise/docs/latest/initial-setup.html">
see the docs.
</a>
<div class="modal__warning-box">
<div class="co-alert co-alert-warning">
<strong>Warning: </strong>
Your configuration and certificates are kept <i>unencrypted</i>. Please keep this file secure.
</div>
</div>
</div>
<div ng-if="!$ctrl.loadedConfig">
Please download your new configuration. For more information, and next steps, please
<a target="_blank" href="https://coreos.com/quay-enterprise/docs/latest/initial-setup.html">
see the docs.
</a>
<div class="modal__warning-box">
<div class="co-alert co-alert-warning">
<strong>Warning: </strong>
Your configuration and certificates are kept <i>unencrypted</i>. Please keep this file secure.
</div>
</div>
</div>
<button class="btn btn-default btn-lg download-button"
ng-click="$ctrl.downloadTarball()">
<i class="fa fa-download" style="margin-right: 10px;"></i>Download Configuration
</button>
</div>
<div ng-if="$ctrl.isKubernetes" class="modal-footer">
<button class="btn btn-primary"
ng-click="$ctrl.goToDeploy()">
<i class="far fa-paper-plane" style="margin-right: 10px;"></i>Go to deployment rollout
</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
</div>

View file

@ -0,0 +1,40 @@
import {Input, Component, Inject, Output, EventEmitter} from 'ng-metadata/core';
const templateUrl = require('./download-tarball-modal.component.html');
const styleUrl = require('./download-tarball-modal.css');
declare const FileSaver: any;
/**
* Initial Screen and Choice in the Config App
*/
@Component({
selector: 'download-tarball-modal',
templateUrl: templateUrl,
styleUrls: [ styleUrl ],
})
export class DownloadTarballModalComponent {
@Input('<') public loadedConfig;
@Input('<') public isKubernetes;
@Output() public chooseDeploy = new EventEmitter<any>();
constructor(@Inject('ApiService') private ApiService) {
}
private downloadTarball(): void {
const errorDisplay: Function = this.ApiService.errorDisplay(
'Could not save configuration. Please report this error.'
);
// We need to set the response type to 'blob', to ensure it's never encoded as a string
// (string encoded binary data can be difficult to transform with js)
// and to make it easier to save (FileSaver expects a blob)
this.ApiService.scGetConfigTarball(null, null, null, null, 'blob').then(function(resp) {
FileSaver.saveAs(resp, 'quay-config.tar.gz');
}, errorDisplay);
}
private goToDeploy() {
this.chooseDeploy.emit({});
}
}

View file

@ -0,0 +1,18 @@
.co-dialog .modal-body.download-tarball-modal {
padding: 15px;
display: flex;
flex-direction: column;
}
.modal__warning-box {
margin-top: 15px;
}
.download-tarball-modal .download-button {
align-self: center;
}
.download-tarball-modal .download-button:hover {
background-color: #dddddd;
text-decoration: none;
}

View file

@ -0,0 +1,46 @@
<div class="file-upload-box-element">
<div class="file-input-container">
<div ng-show="state != 'uploading'">
<form id="file-drop-form-{{ boxId }}">
<input id="file-drop-{{ boxId }}" name="file-drop-{{ boxId }}" class="file-drop"
type="file" files-changed="handleFilesChanged(files)"
accept="{{ getAccepts(extensions) }}">
<label for="file-drop-{{ boxId }}" ng-class="state">
<span class="chosen-file">
<span ng-if="selectedFiles.length">
{{ selectedFiles[0].name }}
<span ng-if="selectedFiles.length > 1">
and {{ selectedFiles.length - 1 }} others...
</span>
</span>
</span><span class="choose-button">
<span>Select file</span>
</span>
</label>
</form>
</div>
<div class="cor-loader-line" ng-if="state == 'checking'"></div>
<div class="status-message" ng-if="state == 'uploading'">
<div class="progress progress-striped active">
<div class="progress-bar" role="progressbar"
aria-valuenow="{{ uploadProgress }}" aria-valuemin="0" aria-valuemax="100"
style="{{ 'width: ' + uploadProgress + '%' }}">
</div>
</div>
Uploading file {{ currentlyUploadingFile.name }}...
</div>
<div class="select-message" ng-if="state == 'clear'">{{ selectMessage }}</div>
<div class="status-message error-message" ng-if="state == 'error'">
<i class="fa fa-times-circle"></i>
{{ message }}
</div>
<div class="status-message okay-message" ng-if="state == 'okay'">
<i class="fa fa-check-circle"></i>
{{ message }}
</div>
</div>
</div>

View file

@ -0,0 +1,169 @@
const templateUrl = require('./file-upload-box.html');
/**
* An element which adds a stylize box for uploading a file.
*/
angular.module('quay-config').directive('fileUploadBox', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl,
replace: false,
transclude: true,
restrict: 'C',
scope: {
'selectMessage': '@selectMessage',
'filesSelected': '&filesSelected',
'filesCleared': '&filesCleared',
'filesValidated': '&filesValidated',
'extensions': '<extensions',
'apiEndpoint': '@apiEndpoint',
'reset': '=?reset'
},
controller: function($rootScope, $scope, $element, ApiService) {
var MEGABYTE = 1000000;
var MAX_FILE_SIZE = MAX_FILE_SIZE_MB * MEGABYTE;
var MAX_FILE_SIZE_MB = 100;
var number = $rootScope.__fileUploadBoxIdCounter || 0;
$rootScope.__fileUploadBoxIdCounter = number + 1;
$scope.boxId = number;
$scope.state = 'clear';
$scope.selectedFiles = [];
var conductUpload = function(file, apiEndpoint, fileId, mimeType, progressCb, doneCb) {
var request = new XMLHttpRequest();
request.open('PUT', '/api/v1/' + apiEndpoint, true);
request.setRequestHeader('Content-Type', mimeType);
request.onprogress = function(e) {
$scope.$apply(function() {
if (e.lengthComputable) { progressCb((e.loaded / e.total) * 100); }
});
};
request.onerror = function() {
$scope.$apply(function() { doneCb(false, 'Error when uploading'); });
};
request.onreadystatechange = function() {
var state = request.readyState;
var status = request.status;
if (state == 4) {
if (Math.floor(status / 100) == 2) {
$scope.$apply(function() { doneCb(true, fileId); });
} else {
var message = request.statusText;
if (status == 413) {
message = 'Selected file too large to upload';
}
$scope.$apply(function() { doneCb(false, message); });
}
}
};
request.send(file);
};
var uploadFiles = function(callback) {
var currentIndex = 0;
var fileIds = [];
var progressCb = function(progress) {
$scope.uploadProgress = progress;
};
var doneCb = function(status, messageOrId) {
if (status) {
fileIds.push(messageOrId);
currentIndex++;
performFileUpload();
} else {
callback(false, messageOrId);
}
};
var performFileUpload = function() {
// If we have finished uploading all of the files, invoke the overall callback
// with the list of file IDs.
if (currentIndex >= $scope.selectedFiles.length) {
callback(true, fileIds);
return;
}
// For the current file, retrieve a file-drop URL from the API for the file.
var currentFile = $scope.selectedFiles[currentIndex];
var mimeType = currentFile.type || 'application/octet-stream';
var data = {
'mimeType': mimeType
};
$scope.currentlyUploadingFile = currentFile;
$scope.uploadProgress = 0;
conductUpload(currentFile, $scope.apiEndpoint, $scope.selectedFiles[0].name, mimeType, progressCb, doneCb);
};
// Start the uploading.
$scope.state = 'uploading';
performFileUpload();
};
$scope.handleFilesChanged = function(files) {
if ($scope.state == 'uploading') { return; }
$scope.message = null;
$scope.selectedFiles = files;
if (files.length == 0) {
$scope.state = 'clear';
$scope.filesCleared();
} else {
for (var i = 0; i < files.length; ++i) {
if (files[i].size > MAX_FILE_SIZE) {
$scope.state = 'error';
$scope.message = 'File ' + files[i].name + ' is larger than the maximum file ' +
'size of ' + MAX_FILE_SIZE_MB + ' MB';
return;
}
}
$scope.state = 'checking';
$scope.filesSelected({
'files': files,
'callback': function(status, message) {
$scope.state = status ? 'okay' : 'error';
$scope.message = message;
if (status) {
$scope.filesValidated({
'files': files,
'uploadFiles': uploadFiles
});
}
}
});
}
};
$scope.getAccepts = function(extensions) {
if (!extensions || !extensions.length) {
return '*';
}
return extensions.join(',');
};
$scope.$watch('reset', function(reset) {
if (reset) {
$scope.state = 'clear';
$element.find('#file-drop-' + $scope.boxId).parent().trigger('reset');
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,18 @@
/**
* Raises the 'filesChanged' event on the scope if a file on the marked <input type="file"> exists.
*/
angular.module('quay-config').directive("filesChanged", [function () {
return {
restrict: 'A',
scope: {
'filesChanged': "&"
},
link: function (scope, element, attributes) {
element.bind("change", function (changeEvent) {
scope.$apply(function() {
scope.filesChanged({'files': changeEvent.target.files});
});
});
}
}
}]);

View file

@ -0,0 +1,71 @@
<div>
<div class="co-dialog modal fade initial-setup-modal in" id="setupModal" style="display: block;">
<div class="modal-backdrop fade in" style="height: 1000px;"></div>
<div class="modal-dialog fade in">
<div class="modal-content kube-deploy-modal">
<!-- Header -->
<div class="modal-header">
<span class="cor-step-bar">
<span class="cor-step active" title="Configure Database" text="1"></span>
<span class="cor-step active" title="Setup Database" icon="database"></span>
<span class="cor-step active" title="Create Superuser" text="2"></span>
<span class="cor-step active" title="Configure Registry" text="3"></span>
<span class="cor-step active" title="Validate Configuration" text="4"></span>
<span class="cor-step active" title="Setup Complete" icon="download"></span>
<span class="cor-step active" title="Deploy Complete" icon="paper-plane"></span>
</span>
<h4 class="modal-title"><span>Deploy configuration</span></h4>
</div>
<!-- Body -->
<div class="modal-body">
<div class="cor-loader" ng-if="$ctrl.state === 'loadingDeployments'"></div>
<div class="kube-deploy-modal__body" ng-if="$ctrl.deploymentsStatus.length > 0">
<span class="kube-deploy-modal__list-header">The following deployments will be affected:</span>
<ul class="kube-deploy-modal__list">
<li class="kube-deploy-modal__list-item" ng-repeat="deployment in $ctrl.deploymentsStatus">
<i class="fa ci-k8s-logo"></i>
<code>{{deployment.name}}</code> with {{deployment.numPods}} <b> {{ deployment.numPods === 1 ? ' pod' : ' pods' }}</b>
</li>
</ul>
<button ng-if="$ctrl.state === 'readyToDeploy'" class="btn btn-primary btn-lg" ng-click="$ctrl.deployConfiguration()">
<i class="far fa-paper-plane" style="margin-right: 10px;"></i>
Populate configuration to deployments
</button>
<div ng-if="$ctrl.state === 'deployingConfiguration'">
<div class="cor-loader"></div>
Deploying configuration...
</div>
<div ng-if="$ctrl.state === 'cyclingDeployments'">
<div class="cor-loader"></div>
<span class="kube-deploy-modal__list-header">Cycling deployments...</span>
<ul class="kube-deploy-modal__list">
<li class="kube-deploy-modal__list-item" ng-repeat="deployment in $ctrl.deploymentsStatus">
<i class="fa ci-k8s-logo"></i>
<code>{{deployment.name}}</code>: {{deployment.message || 'Waiting for deployment information...'}}
</li>
</ul>
</div>
</div>
</div>
<div ng-if="$ctrl.state === 'deployed'" class="modal-footer co-alert co-alert-success">
Configuration successfully rolled out and deployed!
<br>Note: The web interface of the Quay app may take a few minutes to come up.
</div>
<div ng-if="$ctrl.state === 'error'" class="modal-footer alert alert-danger">
{{ $ctrl.errorMessage }}
<div ng-if="$ctrl.rollingBackStatus !== 'none'" class="rollback">
<button ng-if="$ctrl.rollingBackStatus === 'offer'" class="btn btn-default" ng-click="$ctrl.rollbackDeployments()">
<i class="fas fa-history" style="margin-right: 10px;"></i>
Rollback deployments
</button>
<div ng-if="$ctrl.rollingBackStatus === 'rolling'" class="cor-loader-inline"></div>
</div>
</div>
<div ng-if="$ctrl.state === 'rolledBackWarning'" class="modal-footer co-alert co-alert-warning">
Successfully rolled back changes. Please try deploying again with your configuration later.
</div>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
</div>

View file

@ -0,0 +1,153 @@
import { Input, Component, Inject, OnDestroy } from 'ng-metadata/core';
import { AngularPollChannel, PollHandle } from "../../services/services.types";
const templateUrl = require('./kube-deploy-modal.component.html');
const styleUrl = require('./kube-deploy-modal.css');
// The response from the API about deployment rollout status
type DeploymentRollout = {
status: 'available' | 'progressing' | 'failed',
message: string
};
type DeploymentStatus = {
name: string,
numPods: number,
message?: string,
pollHandler?: PollHandle,
}
const DEPLOYMENT_POLL_SLEEPTIME = 5000; /* 5 seconds */
@Component({
selector: 'kube-deploy-modal',
templateUrl,
styleUrls: [ styleUrl ],
})
export class KubeDeployModalComponent implements OnDestroy {
@Input('<') public loadedConfig;
private state
: 'loadingDeployments'
| 'readyToDeploy'
| 'deployingConfiguration'
| 'cyclingDeployments'
| 'deployed'
| 'error'
| 'rolledBackWarning' = 'loadingDeployments';
private errorMessage: string;
private deploymentsStatus: DeploymentStatus[] = [];
private deploymentsCycled: number = 0;
private onDestroyListeners: Function[] = [];
private rollingBackStatus
: 'none'
| 'offer'
| 'rolling' = 'none';
constructor(@Inject('ApiService') private ApiService, @Inject('AngularPollChannel') private AngularPollChannel: AngularPollChannel) {
ApiService.scGetNumDeployments().then(resp => {
this.deploymentsStatus = resp.items.map(dep => ({ name: dep.metadata.name, numPods: dep.spec.replicas }));
this.state = 'readyToDeploy';
}).catch(err => {
this.state = 'error';
this.errorMessage = `There are no Quay deployments active in this namespace. \
Please check that you are running this \
tool in the same namespace as the Red Hat Quay application\
Associated error message: ${err.toString()}`;
})
}
// Call all listeners of the onDestroy
ngOnDestroy(): any {
this.onDestroyListeners.forEach(fn => {
fn()
});
}
deployConfiguration(): void {
this.ApiService.scDeployConfiguration().then(() => {
this.state = 'deployingConfiguration';
const deploymentNames: string[] = this.deploymentsStatus.map(dep => dep.name);
this.ApiService.scCycleQEDeployments({ deploymentNames }).then(() => {
this.state = 'cyclingDeployments';
this.watchDeployments();
}).catch(err => {
this.state = 'error';
this.errorMessage = `Could not cycle the deployments with the new configuration. Error: ${err.toString()}`;
})
}).catch(err => {
this.state = 'error';
this.errorMessage = `Could not deploy the configuration. Error: ${err.toString()}`;
})
}
watchDeployments(): void {
this.deploymentsStatus.forEach(deployment => {
const pollChannel = this.AngularPollChannel.create({
// Have to mock the scope object for the poll channel since we're calling into angular1 code
// We register the onDestroy function to be called later when this object is destroyed
'$on': (_, onDestruction) => { this.onDestroyListeners.push(onDestruction) }
}, this.getDeploymentStatus(deployment), DEPLOYMENT_POLL_SLEEPTIME);
pollChannel.start();
});
}
// Query each deployment every 5s, and stop polling once it's either available or failed
getDeploymentStatus(deployment: DeploymentStatus): (boolean) => void {
return (continue_callback: (shouldContinue: boolean) => void) => {
const params = {
'deployment': deployment.name
};
this.ApiService.scGetDeploymentRolloutStatus(null, params).then((deploymentRollout: DeploymentRollout) => {
if (deploymentRollout.status === 'available') {
continue_callback(false);
this.deploymentsCycled++;
if (this.deploymentsCycled === this.deploymentsStatus.length) {
this.state = 'deployed';
}
} else if (deploymentRollout.status === 'progressing') {
continue_callback(true);
deployment.message = deploymentRollout.message;
} else { // deployment rollout failed
this.state = 'error';
continue_callback(false);
deployment.message = deploymentRollout.message;
this.errorMessage = `Could not cycle deployments: ${deploymentRollout.message}`;
// Only offer rollback if we loaded/populated a config. (Can't rollback an initial setup)
if (this.loadedConfig) {
this.rollingBackStatus = 'offer';
this.errorMessage = `Could not cycle deployments: ${deploymentRollout.message}`;
}
}
}).catch(err => {
continue_callback(false);
this.state = 'error';
this.errorMessage = `Could not cycle the deployments with the new configuration. Error: ${err.toString()}\
Would you like to rollback the deployment to its previous state?`;
// Only offer rollback if we loaded/populated a config. (Can't rollback an initial setup)
if (this.loadedConfig) {
this.rollingBackStatus = 'offer';
this.errorMessage = `Could not get deployment information for: ${deployment}`;
}
});
}
}
rollbackDeployments(): void {
this.rollingBackStatus = 'rolling';
const deploymentNames: string[] = this.deploymentsStatus.map(dep => dep.name);
this.ApiService.scRollbackDeployments({ deploymentNames }).then(() => {
this.state = 'rolledBackWarning';
this.rollingBackStatus = 'none';
}).catch(err => {
this.rollingBackStatus = 'none';
this.state = 'error';
this.errorMessage = `Could not cycle the deployments back to their previous states. Please contact support: ${err.toString()}`;
})
}
}

View file

@ -0,0 +1,46 @@
.kube-deploy-modal__body {
display: flex;
flex-direction: column;
padding: 15px;
list-style: none;
}
.kube-deploy-modal__list {
padding-top: 10px;
padding-left: 15px;
margin-bottom: 15px;
list-style: none;
}
.kube-deploy-modal__list-header {
font-size: 16px;
}
.kube-deploy-modal__list-item {
padding-top: 5px;
}
.kube-deploy-modal__list-item i {
padding-right: 5px;
}
.kube-deploy-modal__body .btn {
align-self: center;
margin-top: 20px;
margin-bottom: 10px;
}
.kube-deploy-modal .rollback {
margin-left: auto;
}
.kube-deploy-modal .co-alert.co-alert-warning {
padding: 25px;
display: flex;
margin-bottom: 0;
}
.kube-deploy-modal .co-alert.co-alert-warning:before {
position: static;
padding-right: 15px;
}

View file

@ -0,0 +1,37 @@
<div>
<div class="co-dialog modal fade initial-setup-modal in" id="setupModal" style="display: block;">
<div class="modal-backdrop fade in" style="height: 1000px;"></div>
<div class="modal-dialog fade in">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<span class="cor-step-bar">
<span class="cor-step active" title="Configure Database" text="1"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Setup Complete" icon="download"></span>
</span>
<h4 class="modal-title"><span>Load Config</span></h4>
</div>
<!-- Body -->
<div class="modal-body">
<span>Please upload the previous configuration</span>
<div class="file-upload-box"
api-endpoint="configapp/tarconfig"
select-message="Select a previous configuration to modify. Must be in tar.gz format"
files-selected="$ctrl.handleTarballSelected(files, callback)"
files-cleared="$ctrl.handleFilesCleared()"
files-validated="$ctrl.tarballValidatedByUploadBox(files, uploadFiles)"
extensions="['application/gzip', '.gz']"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="$ctrl.uploadTarball()" ng-disabled="!$ctrl.readyToSubmit">
Upload Configuration
</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
</div>

View file

@ -0,0 +1,58 @@
import {
Component,
EventEmitter,
Inject,
Output,
} from 'ng-metadata/core';
const templateUrl = require('./load-config.component.html');
declare var bootbox: any;
@Component({
selector: 'load-config',
templateUrl,
})
export class LoadConfigComponent {
private readyToSubmit: boolean = false;
private uploadFunc: Function;
@Output() public configLoaded: EventEmitter<any> = new EventEmitter();
private handleTarballSelected(files: File[], callback: Function) {
this.readyToSubmit = true;
callback(true)
}
private handleTarballCleared() {
this.readyToSubmit = false;
}
private uploadTarball() {
this.uploadFunc(success => {
if (success) {
this.configLoaded.emit({});
} else {
bootbox.dialog({
"message": 'Could not upload configuration. Please check you have provided a valid tar file' +
'If this problem persists, please contact support',
"title": 'Error Loading Configuration',
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
}
});
}
/**
* When files are validated, this is called by the child to give us
* the callback function to upload
* @param files: files to upload
* @param uploadFiles: function to call to upload files
*/
private tarballValidatedByUploadBox(files, uploadFiles) {
this.uploadFunc = uploadFiles;
}
}

View file

@ -0,0 +1,8 @@
[
"javascript",
"python",
"bash",
"nginx",
"xml",
"shell"
]

View file

@ -0,0 +1,24 @@
.markdown-editor-element textarea {
height: 300px;
resize: vertical;
width: 100%;
}
.markdown-editor-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.markdown-editor-buttons {
display: flex;
justify-content: space-between;
}
.markdown-editor-buttons button {
margin: 0 10px;
}
.markdown-editor-buttons button:last-child {
margin: 0;
}

View file

@ -0,0 +1,43 @@
<div class="markdown-editor-element">
<!-- Write/preview tabs -->
<ul class="nav nav-tabs" style="width: 100%;">
<li role="presentation" ng-class="$ctrl.editMode == 'write' ? 'active': ''"
ng-click="$ctrl.changeEditMode('write')">
<a href="#">Write</a>
</li>
<li role="presentation" ng-class="$ctrl.editMode == 'preview' ? 'active': ''"
ng-click="$ctrl.changeEditMode('preview')">
<a href="#">Preview</a>
</li>
<!-- Editing toolbar -->
<li style="float: right;">
<markdown-toolbar ng-if="$ctrl.editMode == 'write'"
(insert-symbol)="$ctrl.insertSymbol($event)"></markdown-toolbar>
</li>
</ul>
<div class="tab-content" style="padding: 10px 0 0 0;">
<div ng-show="$ctrl.editMode == 'write'">
<textarea id="markdown-textarea"
placeholder="Enter {{ ::$ctrl.fieldTitle }}"
ng-model="$ctrl.content"></textarea>
</div>
<div class="markdown-editor-preview"
ng-if="$ctrl.editMode == 'preview'">
<markdown-view content="$ctrl.content"></markdown-view>
</div>
</div>
<div class="markdown-editor-actions">
<div class="markdown-editor-buttons">
<button type="button" class="btn btn-default"
ng-click="$ctrl.discardChanges()">
Close
</button>
<button type="button" class="btn btn-primary"
ng-click="$ctrl.saveChanges()">
Save changes
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,188 @@
import { MarkdownEditorComponent, EditMode } from './markdown-editor.component';
import { MarkdownSymbol } from '../../../types/common.types';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("MarkdownEditorComponent", () => {
var component: MarkdownEditorComponent;
var textarea: Mock<ng.IAugmentedJQuery | any>;
var documentMock: Mock<HTMLElement & Document>;
var $windowMock: Mock<ng.IWindowService>;
beforeEach(() => {
textarea = new Mock<ng.IAugmentedJQuery | any>();
documentMock = new Mock<HTMLElement & Document>();
$windowMock = new Mock<ng.IWindowService>();
const $documentMock: any = [documentMock.Object];
component = new MarkdownEditorComponent($documentMock, $windowMock.Object, 'chrome');
component.textarea = textarea.Object;
});
describe("onBeforeUnload", () => {
it("returns false to alert user about losing current changes", () => {
component.changeEditMode("write");
const allow: boolean = component.onBeforeUnload();
expect(allow).toBe(false);
});
});
describe("ngOnDestroy", () => {
it("removes 'beforeunload' event listener", () => {
$windowMock.setup(mock => mock.onbeforeunload).is(() => 1);
component.ngOnDestroy();
expect($windowMock.Object.onbeforeunload.call(this)).toEqual(null);
});
});
describe("changeEditMode", () => {
it("sets component's edit mode to given mode", () => {
const editMode: EditMode = "preview";
component.changeEditMode(editMode);
expect(component.currentEditMode).toEqual(editMode);
});
});
describe("insertSymbol", () => {
var event: {symbol: MarkdownSymbol};
var markdownSymbols: {type: MarkdownSymbol, characters: string, shiftBy: number}[];
var innerText: string;
beforeEach(() => {
event = {symbol: 'heading1'};
innerText = "Here is some text";
markdownSymbols = [
{type: 'heading1', characters: '# ', shiftBy: 2},
{type: 'heading2', characters: '## ', shiftBy: 3},
{type: 'heading3', characters: '### ', shiftBy: 4},
{type: 'bold', characters: '****', shiftBy: 2},
{type: 'italics', characters: '__', shiftBy: 1},
{type: 'bulleted-list', characters: '- ', shiftBy: 2},
{type: 'numbered-list', characters: '1. ', shiftBy: 3},
{type: 'quote', characters: '> ', shiftBy: 2},
{type: 'link', characters: '[](url)', shiftBy: 1},
{type: 'code', characters: '``', shiftBy: 1},
];
textarea.setup(mock => mock.focus);
textarea.setup(mock => mock.substr).is((start, end) => '');
textarea.setup(mock => mock.val).is((value?) => innerText);
textarea.setup(mock => mock.prop).is((prop) => {
switch (prop) {
case "selectionStart":
return 0;
case "selectionEnd":
return 0;
}
});
documentMock.setup(mock => mock.execCommand).is((commandID, showUI, value) => false);
});
it("focuses on markdown textarea", () => {
component.insertSymbol(event);
expect(<Spy>textarea.Object.focus).toHaveBeenCalled();
});
it("inserts correct characters for given symbol at cursor position", () => {
markdownSymbols.forEach((symbol) => {
event.symbol = symbol.type;
component.insertSymbol(event);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters);
(<Spy>documentMock.Object.execCommand).calls.reset();
});
});
it("splices highlighted selection between inserted characters instead of deleting them", () => {
markdownSymbols.slice(0, 1).forEach((symbol) => {
textarea.setup(mock => mock.prop).is((prop) => {
switch (prop) {
case "selectionStart":
return 0;
case "selectionEnd":
return innerText.length;
}
});
event.symbol = symbol.type;
component.insertSymbol(event);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`);
(<Spy>documentMock.Object.execCommand).calls.reset();
});
});
it("moves cursor to correct position for given symbol", () => {
markdownSymbols.forEach((symbol) => {
event.symbol = symbol.type;
component.insertSymbol(event);
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart');
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy);
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd');
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy);
(<Spy>textarea.Object.prop).calls.reset();
});
});
});
describe("saveChanges", () => {
beforeEach(() => {
component.content = "# Some markdown content";
});
it("emits output event with changed content", (done) => {
component.save.subscribe((event: {editedContent: string}) => {
expect(event.editedContent).toEqual(component.content);
done();
});
component.saveChanges();
});
});
describe("discardChanges", () => {
it("prompts user to confirm discarding changes", () => {
const confirmSpy: Spy = $windowMock.setup(mock => mock.confirm).is((message) => false).Spy;
component.discardChanges();
expect(confirmSpy.calls.argsFor(0)[0]).toEqual(`Are you sure you want to discard your changes?`);
});
it("emits output event with no content if user confirms discarding changes", (done) => {
$windowMock.setup(mock => mock.confirm).is((message) => true);
component.discard.subscribe((event: {}) => {
expect(event).toEqual({});
done();
});
component.discardChanges();
});
it("does not emit output event if user declines confirmation of discarding changes", (done) => {
$windowMock.setup(mock => mock.confirm).is((message) => false);
component.discard.subscribe((event: {}) => {
fail(`Should not emit output event`);
done();
});
component.discardChanges();
done();
});
});
});

View file

@ -0,0 +1,147 @@
import { Component, Inject, Input, Output, EventEmitter, ViewChild, HostListener, OnDestroy } from 'ng-metadata/core';
import { MarkdownSymbol, BrowserPlatform } from './markdown.module';
import './markdown-editor.component.css';
/**
* An editing interface for Markdown content.
*/
@Component({
selector: 'markdown-editor',
templateUrl: require('./markdown-editor.component.html'),
})
export class MarkdownEditorComponent implements OnDestroy {
@Input('<') public content: string;
@Output() public save: EventEmitter<{editedContent: string}> = new EventEmitter();
@Output() public discard: EventEmitter<any> = new EventEmitter();
// Textarea is public for testability, should not be directly accessed
@ViewChild('#markdown-textarea') public textarea: ng.IAugmentedJQuery;
private editMode: EditMode = "write";
constructor(@Inject('$document') private $document: ng.IDocumentService,
@Inject('$window') private $window: ng.IWindowService,
@Inject('BrowserPlatform') private browserPlatform: BrowserPlatform) {
this.$window.onbeforeunload = this.onBeforeUnload.bind(this);
}
@HostListener('window:beforeunload', [])
public onBeforeUnload(): boolean {
return false;
}
public ngOnDestroy(): void {
this.$window.onbeforeunload = () => null;
}
public changeEditMode(newMode: EditMode): void {
this.editMode = newMode;
}
public insertSymbol(event: {symbol: MarkdownSymbol}): void {
this.textarea.focus();
const startPos: number = this.textarea.prop('selectionStart');
const endPos: number = this.textarea.prop('selectionEnd');
const innerText: string = this.textarea.val().slice(startPos, endPos);
var shiftBy: number = 0;
var characters: string = '';
switch (event.symbol) {
case 'heading1':
characters = '# ';
shiftBy = 2;
break;
case 'heading2':
characters = '## ';
shiftBy = 3;
break;
case 'heading3':
characters = '### ';
shiftBy = 4;
break;
case 'bold':
characters = '****';
shiftBy = 2;
break;
case 'italics':
characters = '__';
shiftBy = 1;
break;
case 'bulleted-list':
characters = '- ';
shiftBy = 2;
break;
case 'numbered-list':
characters = '1. ';
shiftBy = 3;
break;
case 'quote':
characters = '> ';
shiftBy = 2;
break;
case 'link':
characters = '[](url)';
shiftBy = 1;
break;
case 'code':
characters = '``';
shiftBy = 1;
break;
}
const cursorPos: number = startPos + shiftBy;
if (startPos != endPos) {
this.insertText(`${characters.slice(0, shiftBy)}${innerText}${characters.slice(shiftBy, characters.length)}`,
startPos,
endPos);
}
else {
this.insertText(characters, startPos, endPos);
}
this.textarea.prop('selectionStart', cursorPos);
this.textarea.prop('selectionEnd', cursorPos);
}
public saveChanges(): void {
this.save.emit({editedContent: this.content});
}
public discardChanges(): void {
if (this.$window.confirm(`Are you sure you want to discard your changes?`)) {
this.discard.emit({});
}
}
public get currentEditMode(): EditMode {
return this.editMode;
}
/**
* Insert text in such a way that the browser adds it to the 'undo' stack. This has different feature support
* depending on the platform.
*/
private insertText(text: string, startPos: number, endPos: number): void {
if (this.browserPlatform === 'firefox') {
// FIXME: Ctrl-Z highlights previous text
this.textarea.val(<string>this.textarea.val().substr(0, startPos) +
text +
<string>this.textarea.val().substr(endPos, this.textarea.val().length));
}
else {
// TODO: Test other platforms (IE...)
this.$document[0].execCommand('insertText', false, text);
}
}
}
/**
* Type representing the current editing mode.
*/
export type EditMode = "write" | "preview";

View file

@ -0,0 +1,14 @@
.markdown-input-container .glyphicon-edit {
float: right;
color: #ddd;
transition: color 0.5s ease-in-out;
}
.markdown-input-container .glyphicon-edit:hover {
cursor: pointer;
color: #444;
}
.markdown-input-placeholder-editable:hover {
cursor: pointer;
}

View file

@ -0,0 +1,29 @@
<div class="markdown-input-container">
<div>
<span class="glyphicon glyphicon-edit"
ng-if="$ctrl.canWrite && !$ctrl.isEditing"
ng-click="$ctrl.editContent()"
data-title="Edit {{ ::$ctrl.fieldTitle }}" data-placement="left" bs-tooltip></span>
<div ng-if="$ctrl.content && !$ctrl.isEditing">
<markdown-view content="$ctrl.content"></markdown-view>
</div>
<!-- Not set and can write -->
<span class="markdown-input-placeholder-editable"
ng-if="!$ctrl.content && $ctrl.canWrite"
ng-click="$ctrl.editContent()">
<i>Click to set {{ ::$ctrl.fieldTitle }}</i>
</span>
<!-- Not set and cannot write -->
<span class="markdown-input-placeholder"
ng-if="!$ctrl.content && !$ctrl.canWrite">
<i>No {{ ::$ctrl.fieldTitle }} has been set</i>
</span>
</div>
<!-- Inline editor -->
<div ng-if="$ctrl.isEditing" style="margin-top: 20px;">
<markdown-editor content="$ctrl.content"
(save)="$ctrl.saveContent($event)"
(discard)="$ctrl.discardContent($event)"></markdown-editor>
</div>
</div>

View file

@ -0,0 +1,34 @@
import { MarkdownInputComponent } from './markdown-input.component';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("MarkdownInputComponent", () => {
var component: MarkdownInputComponent;
beforeEach(() => {
component = new MarkdownInputComponent();
});
describe("editContent", () => {
});
describe("saveContent", () => {
var editedContent: string;
it("emits output event with changed content", (done) => {
editedContent = "# Some markdown here";
component.contentChanged.subscribe((event: {content: string}) => {
expect(event.content).toEqual(editedContent);
done();
});
component.saveContent({editedContent: editedContent});
});
});
describe("discardContent", () => {
});
});

View file

@ -0,0 +1,34 @@
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
import './markdown-input.component.css';
/**
* Displays editable Markdown content.
*/
@Component({
selector: 'markdown-input',
templateUrl: require('./markdown-input.component.html'),
})
export class MarkdownInputComponent {
@Input('<') public content: string;
@Input('<') public canWrite: boolean;
@Input('@') public fieldTitle: string;
@Output() public contentChanged: EventEmitter<{content: string}> = new EventEmitter();
private isEditing: boolean = false;
public editContent(): void {
this.isEditing = true;
}
public saveContent(event: {editedContent: string}): void {
this.contentChanged.emit({content: event.editedContent});
this.isEditing = false;
}
public discardContent(event: any): void {
this.isEditing = false;
}
}

View file

@ -0,0 +1,8 @@
.markdown-toolbar-element .dropdown-menu li > * {
margin: 0 4px;
}
.markdown-toolbar-element .dropdown-menu li:hover {
cursor: pointer;
background-color: #e6e6e6;
}

View file

@ -0,0 +1,61 @@
<div class="markdown-toolbar-element">
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<div class="btn-group">
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
data-toggle="dropdown"
data-title="Add header" data-container="body" bs-tooltip>
<span class="glyphicon glyphicon-text-size"></span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading1'})"><h2>Heading</h2></li>
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading2'})"><h3>Heading</h3></li>
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading3'})"><h4>Heading</h4></li>
</ul>
</div>
<button type="button" class="btn btn-default btn-sm"
data-title="Bold" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'bold'})">
<span class="glyphicon glyphicon-bold"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Italics" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'italics'})">
<span class="glyphicon glyphicon-italic"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default btn-sm"
data-title="Block quote" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'quote'})">
<i class="fa fa-quote-left" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Code snippet" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'code'})">
<span class="glyphicon glyphicon-menu-left" style="margin-right: -6px;"></span>
<span class="glyphicon glyphicon-menu-right"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="URL" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'link'})">
<span class="glyphicon glyphicon-link"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default btn-sm"
data-title="Bulleted list" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'bulleted-list'})">
<span class="glyphicon glyphicon-list"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Numbered list" data-container="body" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'numbered-list'})">
<i class="fa fa-list-ol" aria-hidden="true"></i>
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
import { MarkdownToolbarComponent } from './markdown-toolbar.component';
import { MarkdownSymbol } from '../../../types/common.types';
describe("MarkdownToolbarComponent", () => {
var component: MarkdownToolbarComponent;
beforeEach(() => {
component = new MarkdownToolbarComponent();
});
});

View file

@ -0,0 +1,17 @@
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
import { MarkdownSymbol } from './markdown.module';
import './markdown-toolbar.component.css';
/**
* Toolbar containing Markdown symbol shortcuts.
*/
@Component({
selector: 'markdown-toolbar',
templateUrl: require('./markdown-toolbar.component.html'),
})
export class MarkdownToolbarComponent {
@Input('<') public allowUndo: boolean = true;
@Output() public insertSymbol: EventEmitter<{symbol: MarkdownSymbol}> = new EventEmitter();
}

View file

@ -0,0 +1,12 @@
.markdown-view-content {
word-wrap: break-word;
overflow: hidden;
}
.markdown-view-content p {
margin: 0;
}
code * {
font-family: "Lucida Console", Monaco, monospace;
}

View file

@ -0,0 +1,2 @@
<div class="markdown-view-content"
ng-bind-html="$ctrl.convertedHTML"></div>

View file

@ -0,0 +1,79 @@
import { MarkdownViewComponent } from './markdown-view.component';
import { SimpleChanges } from 'ng-metadata/core';
import { Converter, ConverterOptions } from 'showdown';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("MarkdownViewComponent", () => {
var component: MarkdownViewComponent;
var markdownConverterMock: Mock<Converter>;
var $sceMock: Mock<ng.ISCEService>;
var $sanitizeMock: ng.sanitize.ISanitizeService;
beforeEach(() => {
markdownConverterMock = new Mock<Converter>();
$sceMock = new Mock<ng.ISCEService>();
$sanitizeMock = jasmine.createSpy('$sanitizeSpy').and.callFake((html: string) => html);
component = new MarkdownViewComponent(markdownConverterMock.Object, $sceMock.Object, $sanitizeMock);
});
describe("ngOnChanges", () => {
var changes: SimpleChanges;
var markdown: string;
var expectedPlaceholder: string;
var markdownChars: string[];
beforeEach(() => {
changes = {};
markdown = `## Heading\n Code line\n\n- Item\n> Quote\`code snippet\`\n\nThis is my project!`;
expectedPlaceholder = `<p style="visibility:hidden">placeholder</p>`;
markdownChars = ['#', '-', '>', '`'];
markdownConverterMock.setup(mock => mock.makeHtml).is((text) => text);
$sceMock.setup(mock => mock.trustAsHtml).is((html) => html);
});
it("calls markdown converter to convert content to HTML when content is changed", () => {
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
it("only converts first line of content to HTML if flag is set when content is changed", () => {
component.firstLineOnly = true;
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
const expectedHtml: string = markdown.split('\n')
.filter(line => line.indexOf(' ') != 0)
.filter(line => line.trim().length != 0)
.filter(line => markdownChars.indexOf(line.trim()[0]) == -1)[0];
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(expectedHtml);
});
it("sets converted HTML to be a placeholder if flag is set and content is empty", () => {
component.placeholderNeeded = true;
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled();
expect((<Spy>$sceMock.Object.trustAsHtml).calls.argsFor(0)[0]).toEqual(expectedPlaceholder);
});
it("sets converted HTML to empty string if placeholder flag is false and content is empty", () => {
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
it("calls $sanitize service to sanitize changed HTML content", () => {
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>$sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
});
});

View file

@ -0,0 +1,48 @@
import { Component, Input, Inject, OnChanges, SimpleChanges } from 'ng-metadata/core';
import { Converter, ConverterOptions } from 'showdown';
import './markdown-view.component.css';
/**
* Renders Markdown content to HTML.
*/
@Component({
selector: 'markdown-view',
templateUrl: require('./markdown-view.component.html'),
})
export class MarkdownViewComponent implements OnChanges {
@Input('<') public content: string;
@Input('<') public firstLineOnly: boolean = false;
@Input('<') public placeholderNeeded: boolean = false;
private convertedHTML: string = '';
private readonly placeholder: string = `<p style="visibility:hidden">placeholder</p>`;
private readonly markdownChars: string[] = ['#', '-', '>', '`'];
constructor(@Inject('markdownConverter') private markdownConverter: Converter,
@Inject('$sce') private $sce: ng.ISCEService,
@Inject('$sanitize') private $sanitize: ng.sanitize.ISanitizeService) {
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes['content']) {
if (!changes['content'].currentValue && this.placeholderNeeded) {
this.convertedHTML = this.$sce.trustAsHtml(this.placeholder);
} else if (this.firstLineOnly && changes['content'].currentValue) {
const firstLine: string = changes['content'].currentValue.split('\n')
// Skip code lines
.filter(line => line.indexOf(' ') != 0)
// Skip empty lines
.filter(line => line.trim().length != 0)
// Skip control lines
.filter(line => this.markdownChars.indexOf(line.trim()[0]) == -1)[0];
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(firstLine));
} else {
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(changes['content'].currentValue || ''));
}
}
}
}

View file

@ -0,0 +1,111 @@
import { NgModule } from 'ng-metadata/core';
import { Converter } from 'showdown';
import * as showdown from 'showdown';
import { registerLanguage, highlightAuto } from 'highlight.js/lib/highlight';
import 'highlight.js/styles/vs.css';
const highlightedLanguages: string[] = require('./highlighted-languages.constant.json');
/**
* A type representing a Markdown symbol.
*/
export type MarkdownSymbol = 'heading1'
| 'heading2'
| 'heading3'
| 'bold'
| 'italics'
| 'bulleted-list'
| 'numbered-list'
| 'quote'
| 'code'
| 'link'
| 'code';
/**
* Type representing current browser platform.
* TODO: Add more browser platforms.
*/
export type BrowserPlatform = "firefox"
| "chrome";
/**
* Constant representing current browser platform. Used for determining available features.
* TODO Only rudimentary implementation, should prefer specific feature detection strategies instead.
*/
export const browserPlatform: BrowserPlatform = (() => {
if (navigator.userAgent.toLowerCase().indexOf('firefox') != -1) {
return 'firefox';
}
else {
return 'chrome';
}
})();
/**
* Dynamically fetch and register a new language with Highlight.js
*/
export const addHighlightedLanguage = (language: string): Promise<{}> => {
return new Promise(async(resolve, reject) => {
try {
// TODO: Use `import()` here instead of `require` after upgrading to TypeScript 2.4
const langModule = require(`highlight.js/lib/languages/${language}`);
registerLanguage(language, langModule);
console.debug(`Language ${language} registered for syntax highlighting`);
resolve();
} catch (error) {
console.debug(`Language ${language} not supported for syntax highlighting`);
reject(error);
}
});
};
/**
* Showdown JS extension for syntax highlighting using Highlight.js. Will attempt to register detected languages.
*/
export const showdownHighlight = (): showdown.FilterExtension => {
const htmlunencode = (text: string) => {
return (text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>'));
};
const left = '<pre><code\\b[^>]*>';
const right = '</code></pre>';
const flags = 'g';
const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => {
const language: string = leftSide.slice(leftSide.indexOf('language-') + ('language-').length,
leftSide.indexOf('"', leftSide.indexOf('language-')));
addHighlightedLanguage(language).catch(error => null);
match = htmlunencode(match);
return leftSide + highlightAuto(match).value + rightSide;
};
return {
type: 'output',
filter: (text, converter, options) => {
return (<any>showdown).helper.replaceRecursiveRegExp(text, replacement, left, right, flags);
}
};
};
// Import default syntax-highlighting supported languages
highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName));
/**
* Markdown editor and view module.
*/
@NgModule({
imports: [],
declarations: [],
providers: [
{provide: 'markdownConverter', useValue: new Converter({extensions: [<any>showdownHighlight]})},
{provide: 'BrowserPlatform', useValue: browserPlatform},
],
})
export class MarkdownModule {
}

View file

@ -0,0 +1 @@
<span class="registry-name-element">{{ name }}</span>

View file

@ -0,0 +1,22 @@
const templateUrl = require('./registry-name.html');
/**
* An element which displays the name of the registry (optionally the short name).
*/
angular.module('quay-config').directive('registryName', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl,
replace: false,
transclude: true,
restrict: 'C',
scope: {
'isShort': '=isShort'
},
controller: function($scope, $element, Config) {
$scope.name = $scope.isShort ? Config.REGISTRY_TITLE_SHORT : Config.REGISTRY_TITLE;
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,88 @@
<div class="request-service-key-dialog-element">
<!-- Modal message dialog -->
<div class="co-dialog modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-show="!working">&times;</button>
<h4 class="modal-title">Create key for service {{ requestKeyInfo.service }}</h4>
</div>
<div class="modal-body" ng-show="working">
<div class="cor-loader"></div>
</div>
<div class="modal-body" ng-show="!working">
<!-- Step 1 (generate) -->
<div ng-show="step == 1">
<form name="createForm" ng-submit="createPresharedKey()">
<table class="co-form-table">
<tr>
<td><label for="create-key-name">Key Name:</label></td>
<td>
<input class="form-control" name="create-key-name" type="text" ng-model="preshared.name" placeholder="Friendly Key Name">
<span class="co-help-text">
A friendly name for the key for later reference.
</span>
</td>
</tr>
<tr>
<td><label for="create-key-expiration">Expiration date (optional):</label></td>
<td>
<span class="datetime-picker" datetime="preshared.expiration"></span>
<span class="co-help-text">
The date and time that the key expires. If left blank, the key will never expire.
</span>
</td>
</tr>
<tr>
<td><label for="create-key-notes">Approval Notes (optional):</label></td>
<td>
<markdown-input content="preshared.notes"
can-write="true"
(content-changed)="updateNotes($event.content)"
field-title="notes"></markdown-input>
<span class="co-help-text">
Optional notes for additional human-readable information about why the key was created.
</span>
</td>
</tr>
</table>
</form>
</div>
<!-- Step 2 (generate) -->
<div ng-show="step == 2">
<div class="co-alert co-alert-warning">
The following key has been generated for service <code>{{ requestKeyInfo.service }}</code>.
<br><br>
Please copy the key's ID and copy/download the key's private contents and place it in the directory with the service's configuration.
<br><br>
<strong>Once this dialog is closed this private key will not be accessible anywhere else!</strong>
</div>
<label>Key ID:</label>
<div><code>{{ createdKey.kid }}</code></div>
<label>Private Key (PEM):</label>
<textarea class="key-display form-control" onclick="this.focus();this.select()" readonly>{{ createdKey.private_key }}</textarea>
</div>
</div>
<div class="modal-footer" ng-show="!working">
<button type="button" class="btn btn-primary" ng-show="step == 1"
ng-disabled="createForm.$invalid"
ng-click="createPresharedKey()">
Generate Key
</button>
<button type="button" class="btn btn-primary" ng-click="downloadPrivateKey(createdKey)" ng-if="createdKey && isDownloadSupported()">
<i class="fa fa-download"></i> Download Private Key
</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step == 2">Close</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step != 2">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>
</div>

View file

@ -0,0 +1,100 @@
const templateUrl = require('./request-service-key-dialog.html');
/**
* An element which displays a dialog for requesting or creating a service key.
*/
angular.module('quay-config').directive('requestServiceKeyDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl,
replace: false,
transclude: true,
restrict: 'C',
scope: {
'requestKeyInfo': '=requestKeyInfo',
'keyCreated': '&keyCreated'
},
controller: function($scope, $element, $timeout, ApiService) {
var handleNewKey = function(key) {
var data = {
'notes': 'Approved during setup of service ' + key.service
};
var params = {
'kid': key.kid
};
ApiService.approveServiceKey(data, params).then(function(resp) {
$scope.keyCreated({'key': key});
$scope.step = 2;
}, ApiService.errorDisplay('Could not approve service key'));
};
$scope.show = function() {
$scope.working = false;
$scope.step = 1;
var notes = 'Created during setup for service `' + $scope.requestKeyInfo.service + '`';
if ($scope.requestKeyInfo.newKey) {
notes = 'Replacement key for service `' + $scope.requestKeyInfo.service + '`';
}
$scope.preshared = {
'name': $scope.requestKeyInfo.service + ' Service Key',
'notes': notes
};
$element.find('.modal').modal({});
};
$scope.hide = function() {
$scope.loading = false;
$element.find('.modal').modal('hide');
};
$scope.isDownloadSupported = function() {
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Doesn't work properly in Safari, sadly.
return false;
}
try { return !!new Blob(); } catch(e) {}
return false;
};
$scope.downloadPrivateKey = function(key) {
var blob = new Blob([key.private_key]);
FileSaver.saveAs(blob, key.service + '.pem');
};
$scope.createPresharedKey = function() {
$scope.working = true;
var data = {
'name': $scope.preshared.name,
'service': $scope.requestKeyInfo.service,
'expiration': $scope.preshared.expiration || null,
'notes': $scope.preshared.notes
};
ApiService.createServiceKey(data).then(function(resp) {
$scope.working = false;
$scope.step = 2;
$scope.createdKey = resp;
$scope.keyCreated({'key': resp});
}, ApiService.errorDisplay('Could not create service key'));
};
$scope.updateNotes = function(content) {
$scope.preshared.notes = content;
};
$scope.$watch('requestKeyInfo', function(info) {
if (info && info.service) {
$scope.show();
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,65 @@
import { NgModule } from 'ng-metadata/core';
import * as restangular from 'restangular';
import { ConfigSetupAppComponent } from './components/config-setup-app/config-setup-app.component';
import { DownloadTarballModalComponent } from './components/download-tarball-modal/download-tarball-modal.component';
import { LoadConfigComponent } from './components/load-config/load-config.component';
import { KubeDeployModalComponent } from './components/kube-deploy-modal/kube-deploy-modal.component';
import { MarkdownModule } from './components/markdown/markdown.module';
import { MarkdownInputComponent } from './components/markdown/markdown-input.component';
import { MarkdownViewComponent } from './components/markdown/markdown-view.component';
import { MarkdownToolbarComponent } from './components/markdown/markdown-toolbar.component';
import { MarkdownEditorComponent } from './components/markdown/markdown-editor.component';
const quayDependencies: any[] = [
'restangular',
'ngCookies',
'angularFileUpload',
'ngSanitize',
];
@NgModule(({
imports: quayDependencies,
declarations: [],
providers: [
provideConfig,
]
}))
class DependencyConfig{}
provideConfig.$inject = [
'$provide',
'$injector',
'$compileProvider',
'RestangularProvider',
];
function provideConfig($provide: ng.auto.IProvideService,
$injector: ng.auto.IInjectorService,
$compileProvider: ng.ICompileProvider,
RestangularProvider: any): void {
// Configure the API provider.
RestangularProvider.setBaseUrl('/api/v1/');
}
@NgModule({
imports: [
DependencyConfig,
MarkdownModule,
],
declarations: [
ConfigSetupAppComponent,
DownloadTarballModalComponent,
LoadConfigComponent,
KubeDeployModalComponent,
MarkdownInputComponent,
MarkdownViewComponent,
MarkdownToolbarComponent,
MarkdownEditorComponent,
],
providers: []
})
export class ConfigAppModule {}

View file

@ -0,0 +1,8 @@
<div class="config-bool-field-element">
<form name="fieldform" novalidate>
<label>
<input type="checkbox" ng-model="binding">
<span ng-transclude/>
</label>
</form>
</div>

View file

@ -0,0 +1,77 @@
<div class="config-certificates-field-element">
<div class="resource-view" resource="certificatesResource" error-message="'Could not load certificates list'">
<!-- File -->
<div class="co-alert co-alert-warning" ng-if="certInfo.status == 'file'">
<code>extra_ca_certs</code> is a single file and cannot be processed by this tool. If a valid and appended list of certificates, they will be installed on container startup.
</div>
<div ng-if="certInfo.status != 'file'">
<div class="description">
<p>This section lists any custom or self-signed SSL certificates that are installed in the <span class="registry-name"></span> container on startup after being read from the <code>extra_ca_certs</code> directory in the configuration volume.
</p>
<p>
Custom certificates are typically used in place of publicly signed certificates for corporate-internal services.
</p>
<p>Please <strong>make sure</strong> that all custom names used for downstream services (such as Clair) are listed in the certificates below.</p>
</div>
<table class="config-table" style="margin-bottom: 20px;">
<tr>
<td>Upload certificates:</td>
<td>
<div class="file-upload-box"
api-endpoint="superuser/customcerts"
select-message="Select custom certificate to add to configuration. Must be in PEM format and end extension '.crt'"
files-selected="handleCertsSelected(files, callback)"
reset="resetUpload"
extensions="['.crt']"></div>
</td>
</tr>
</table>
<table class="co-table">
<thead>
<td>Certificate Filename</td>
<td>Status</td>
<td>Names Handled</td>
<td class="options-col"></td>
</thead>
<tr ng-repeat="certificate in certInfo.certs" ng-if="!certsUploading">
<td>{{ certificate.path }}</td>
<td class="cert-status">
<div ng-if="certificate.error" class="red">
<i class="fa fa-exclamation-circle"></i>
Error: {{ certificate.error }}
</div>
<div ng-if="certificate.expired" class="orange">
<i class="fa fa-exclamation-triangle"></i>
Certificate is expired
</div>
<div ng-if="!certificate.error && !certificate.expired" class="green">
<i class="fa fa-check-circle"></i>
Certificate is valid
</div>
</td>
<td>
<div class="empty" ng-if="!certificate.names">(None)</div>
<a class="dns-name" ng-href="http://{{ name }}" ng-repeat="name in certificate.names" ng-safenewtab>{{ name }}</a>
</td>
<td class="options-col">
<span class="cor-options-menu">
<span class="cor-option" option-click="deleteCert(certificate.path)">
<i class="fa fa-times"></i> Delete Certificate
</span>
</span>
</td>
</tr>
</table>
<div ng-if="certsUploading" style="margin-top: 20px; text-align: center;">
<div class="cor-loader-inline"></div>
Uploading, validating and updating certificate(s)
</div>
<div class="empty" ng-if="!certInfo.certs.length && !certsUploading" style="margin-top: 20px;">
<div class="empty-primary-msg">No custom certificates found.</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,46 @@
<div class="config-contact-field-element">
<table>
<tr>
<td>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
<span ng-switch="kind">
<span ng-switch-when="mailto"><i class="fa fa-envelope"></i>E-mail</span>
<span ng-switch-when="irc"><i class="fa fa-comment"></i>IRC</span>
<span ng-switch-when="tel"><i class="fa fa-phone"></i>Phone</span>
<span ng-switch-default><i class="fa fa-ticket-alt"></i>URL</span>
</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'mailto'">
<i class="fa fa-envelope"></i> E-mail
</a>
</li>
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'irc'">
<i class="fa fa-comment"></i> IRC
</a>
</li>
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'tel'">
<i class="fa fa-phone"></i> Telephone
</a>
</li>
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'http'">
<i class="fa fa-ticket-alt"></i> URL
</a>
</li>
</ul>
</div>
</td>
<td>
<form>
<input class="form-control" placeholder="{{ getPlaceholder(kind) }}" ng-model="value">
</form>
</td>
</tr>
</table>
</div>

View file

@ -0,0 +1,4 @@
<div class="config-contacts-field-element">
<div class="config-contact-field" binding="item.value" ng-repeat="item in items">
</div>
</div>

View file

@ -0,0 +1,13 @@
<div class="config-file-field-element">
<span ng-show="uploadProgress == null">
<span ng-if="hasFile">
<code>/conf/stack/{{ filename }}</code>
<span style="margin-left: 20px; display: inline-block;">Select a replacement file:</span>
</span>
<span class="nofile" ng-if="!hasFile && skipCheckFile != 'true'">Please select a file to upload as <b>{{ filename }}</b>: </span>
<input type="file" ng-file-select="onFileSelect($files)">
</span>
<span ng-show="uploadProgress != null">
Uploading file as <strong>{{ filename }}</strong>... {{ uploadProgress }}%
</span>
</div>

View file

@ -0,0 +1,17 @@
<div class="config-list-field-element">
<ul ng-show="binding && binding.length">
<li class="item" ng-repeat="item in binding">
<span class="item-title">{{ item }}</span>
<span class="item-delete">
<a ng-click="removeItem(item)">Remove</a>
</span>
</li>
</ul>
<span class="empty" ng-if="!binding || binding.length == 0">No {{ itemTitle }}s defined</span>
<form class="form-control-container" ng-submit="addItem()">
<input type="text" class="form-control" placeholder="{{ placeholder }}"
ng-pattern="getRegexp(itemPattern)"
ng-model="newItemName" style="display: inline-block">
<button class="btn btn-default" style="display: inline-block">Add</button>
</form>
</div>

View file

@ -0,0 +1,20 @@
<div class="config-map-field-element">
<table class="table" ng-show="hasValues(binding)">
<tr class="item" ng-repeat="(key, value) in binding">
<td class="item-title">{{ key }}</td>
<td class="item-value">{{ value }}</td>
<td class="item-delete">
<a ng-click="removeKey(key)">Remove</a>
</td>
</tr>
</table>
<span class="empty" ng-if="!hasValues(binding)">No entries defined</span>
<form class="form-control-container" ng-submit="addEntry()">
Add Key-Value:
<select ng-model="newKey">
<option ng-repeat="key in keys" value="{{ key }}">{{ key }}</option>
</select>
<input type="text" class="form-control" placeholder="Value" ng-model="newValue">
<button class="btn btn-default" style="display: inline-block">Add Entry</button>
</form>
</div>

View file

@ -0,0 +1,6 @@
<div class="config-numeric-field-element">
<form name="fieldform" novalidate>
<input type="number" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="bindinginternal" ng-trim="false" ng-minlength="1" required>
</form>
</div>

View file

@ -0,0 +1 @@
<div class="config-parsed-field-element"></div>

View file

@ -0,0 +1,9 @@
<div class="config-password-field-element">
<form name="fieldform" novalidate>
<input type="password" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="binding" ng-trim="false" ng-minlength="1" ng-required="!isOptional">
<div class="alert alert-danger" ng-show="errorMessage">
{{ errorMessage }}
</div>
</form>
</div>

View file

@ -0,0 +1,30 @@
<div class="config-service-key-field-element">
<!-- Loading -->
<div class="cor-loader" ng-if="loading"></div>
<!-- Loading error -->
<div class="co-alert co-alert-warning" ng-if="loadError">
Could not load service keys
</div>
<!-- Key config -->
<div ng-show="!loading && !loadError">
<div ng-show="hasValidKey">
<i class="fa fa-check"></i>
Valid key for service <code>{{ serviceName }}</code> exists
<a class="co-modify-link" ng-click="showRequestServiceKey(true)">Assign New Key</a>
</div>
<div ng-show="!hasValidKey">
No valid key found for service <code>{{ serviceName }}</code>
<a class="co-modify-link" ng-click="showRequestServiceKey()">Create Key</a>
</div>
</div>
<!-- Note: This field is a hidden text field that binds to a model that is set to non-empty
when there is a valid key. This allows us to use the existing Angular form validation
code.
-->
<input type="text" ng-model="hasValidKeyStr" ng-required="true" style="position: absolute; top: 0px; left: 0px; visibility: hidden; width: 0px; height: 0px;">
<div class="request-service-key-dialog" request-key-info="requestKeyInfo" key-created="handleKeyCreated(key)"></div>
</div>

View file

@ -0,0 +1,10 @@
<div class="config-string-field-element">
<form name="fieldform" novalidate>
<input type="text" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="binding" ng-trim="false" ng-minlength="1"
ng-pattern="getRegexp(pattern)" ng-required="!isOptional">
<div class="alert alert-danger" ng-show="errorMessage">
{{ errorMessage }}
</div>
</form>
</div>

View file

@ -0,0 +1,6 @@
<div class="config-string-list-field-element">
<form name="fieldform" novalidate>
<input type="text" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="internalBinding" ng-trim="true" ng-minlength="1" ng-required="!isOptional">
</form>
</div>

View file

@ -0,0 +1,10 @@
<div class="config-variable-field-element">
<div class="btn-group">
<button type="button" class="btn btn-default"
ng-repeat="section in sections"
ng-click="setSection(section)"
ng-class="section == currentSection ? 'active' : ''">{{ section.title }}</button>
</div>
<span ng-transclude></span>
</div>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

37
config_app/js/main.ts Normal file
View file

@ -0,0 +1,37 @@
// imports shims, etc
import 'core-js';
import * as angular from 'angular';
import { ConfigAppModule } from './config-app.module';
import { bundle } from 'ng-metadata/core';
// load all app dependencies
require('../static/lib/angular-file-upload.min.js');
require('../../static/js/tar');
const ng1QuayModule: string = bundle(ConfigAppModule, []).name;
angular.module('quay-config', [ng1QuayModule])
.run(() => {
});
declare var require: any;
function requireAll(r) {
r.keys().forEach(r);
}
// load all services
requireAll(require.context('./services', true, /\.js$/));
// load all the components after services
requireAll(require.context('./setup', true, /\.js$/));
requireAll(require.context('./core-config-setup', true, /\.js$/));
requireAll(require.context('./components', true, /\.js$/));
// load config-app specific css
requireAll(require.context('../static/css', true, /\.css$/));
// Load all the main quay css
requireAll(require.context('../../static/css', true, /\.css$/));
requireAll(require.context('../../static/lib', true, /\.css$/));

View file

@ -0,0 +1,107 @@
/**
* Specialized class for conducting an HTTP poll, while properly preventing multiple calls.
*/
angular.module('quay-config').factory('AngularPollChannel',
['ApiService', '$timeout', 'DocumentVisibilityService', 'CORE_EVENT', '$rootScope',
function(ApiService, $timeout, DocumentVisibilityService, CORE_EVENT, $rootScope) {
var _PollChannel = function(scope, requester, opt_sleeptime) {
this.scope_ = scope;
this.requester_ = requester;
this.sleeptime_ = opt_sleeptime || (60 * 1000 /* 60s */);
this.timer_ = null;
this.working = false;
this.polling = false;
this.skipping = false;
var that = this;
var visibilityHandler = $rootScope.$on(CORE_EVENT.DOC_VISIBILITY_CHANGE, function() {
// If the poll channel was skipping because the visibility was hidden, call it immediately.
if (that.skipping && !DocumentVisibilityService.isHidden()) {
that.call_();
}
});
scope.$on('$destroy', function() {
that.stop();
visibilityHandler();
});
};
_PollChannel.prototype.setSleepTime = function(sleepTime) {
this.sleeptime_ = sleepTime;
this.stop();
this.start(true);
};
_PollChannel.prototype.stop = function() {
if (this.timer_) {
$timeout.cancel(this.timer_);
this.timer_ = null;
this.polling = false;
}
this.skipping = false;
this.working = false;
};
_PollChannel.prototype.start = function(opt_skipFirstCall) {
// Make sure we invoke call outside the normal digest cycle, since
// we'll call $scope.$apply ourselves.
var that = this;
setTimeout(function() {
if (opt_skipFirstCall) {
that.setupTimer_();
return;
}
that.call_();
}, 0);
};
_PollChannel.prototype.call_ = function() {
if (this.working) { return; }
// If the document is currently hidden, skip the call.
if (DocumentVisibilityService.isHidden()) {
this.skipping = true;
this.setupTimer_();
return;
}
var that = this;
this.working = true;
$timeout(function() {
that.requester_(function(status) {
if (status) {
that.working = false;
that.skipping = false;
that.setupTimer_();
} else {
that.stop();
}
});
}, 0);
};
_PollChannel.prototype.setupTimer_ = function() {
if (this.timer_) { return; }
var that = this;
this.polling = true;
this.timer_ = $timeout(function() {
that.timer_ = null;
that.call_();
}, this.sleeptime_)
};
var service = {
'create': function(scope, requester, opt_sleeptime) {
return new _PollChannel(scope, requester, opt_sleeptime);
}
};
return service;
}]);

View file

@ -0,0 +1,335 @@
/**
* Service which exposes the server-defined API as a nice set of helper methods and automatic
* callbacks. Any method defined on the server is exposed here as an equivalent method. Also
* defines some helper functions for working with API responses.
*/
angular.module('quay-config').factory('ApiService', ['Restangular', '$q', 'UtilService', function(Restangular, $q, UtilService) {
var apiService = {};
if (!window.__endpoints) {
return apiService;
}
var getResource = function(getMethod, operation, opt_parameters, opt_background) {
var resource = {};
resource.withOptions = function(options) {
this.options = options;
return this;
};
resource.get = function(processor, opt_errorHandler) {
var options = this.options;
var result = {
'loading': true,
'value': null,
'hasError': false
};
getMethod(options, opt_parameters, opt_background, true).then(function(resp) {
result.value = processor(resp);
result.loading = false;
}, function(resp) {
result.hasError = true;
result.loading = false;
if (opt_errorHandler) {
opt_errorHandler(resp);
}
});
return result;
};
return resource;
};
var buildUrl = function(path, parameters) {
// We already have /api/v1/ on the URLs, so remove them from the paths.
path = path.substr('/api/v1/'.length, path.length);
// Build the path, adjusted with the inline parameters.
var used = {};
var url = '';
for (var i = 0; i < path.length; ++i) {
var c = path[i];
if (c == '{') {
var end = path.indexOf('}', i);
var varName = path.substr(i + 1, end - i - 1);
if (!parameters[varName]) {
throw new Error('Missing parameter: ' + varName);
}
used[varName] = true;
url += parameters[varName];
i = end;
continue;
}
url += c;
}
// Append any query parameters.
var isFirst = true;
for (var paramName in parameters) {
if (!parameters.hasOwnProperty(paramName)) { continue; }
if (used[paramName]) { continue; }
var value = parameters[paramName];
if (value) {
url += isFirst ? '?' : '&';
url += paramName + '=' + encodeURIComponent(value)
isFirst = false;
}
}
return url;
};
var getGenericOperationName = function(userOperationName) {
return userOperationName.replace('User', '');
};
var getMatchingUserOperationName = function(orgOperationName, method, userRelatedResource) {
if (userRelatedResource) {
if (userRelatedResource[method.toLowerCase()]) {
return userRelatedResource[method.toLowerCase()]['operationId'];
}
}
throw new Error('Could not find user operation matching org operation: ' + orgOperationName);
};
var freshLoginInProgress = [];
var reject = function(msg) {
for (var i = 0; i < freshLoginInProgress.length; ++i) {
freshLoginInProgress[i].deferred.reject({'data': {'message': msg}});
}
freshLoginInProgress = [];
};
var retry = function() {
for (var i = 0; i < freshLoginInProgress.length; ++i) {
freshLoginInProgress[i].retry();
}
freshLoginInProgress = [];
};
var freshLoginFailCheck = function(opName, opArgs) {
return function(resp) {
var deferred = $q.defer();
// If the error is a fresh login required, show the dialog.
// TODO: remove error_type (old style error)
var fresh_login_required = resp.data['title'] == 'fresh_login_required' || resp.data['error_type'] == 'fresh_login_required';
if (resp.status == 401 && fresh_login_required) {
var retryOperation = function() {
apiService[opName].apply(apiService, opArgs).then(function(resp) {
deferred.resolve(resp);
}, function(resp) {
deferred.reject(resp);
});
};
var verifyNow = function() {
if (!$('#freshPassword').val()) {
return;
}
var info = {
'password': $('#freshPassword').val()
};
$('#freshPassword').val('');
// Conduct the sign in of the user.
apiService.verifyUser(info).then(function() {
// On success, retry the operations. if it succeeds, then resolve the
// deferred promise with the result. Otherwise, reject the same.
retry();
}, function(resp) {
// Reject with the sign in error.
reject('Invalid verification credentials');
});
};
// Add the retry call to the in progress list. If there is more than a single
// in progress call, we skip showing the dialog (since it has already been
// shown).
freshLoginInProgress.push({
'deferred': deferred,
'retry': retryOperation
})
if (freshLoginInProgress.length > 1) {
return deferred.promise;
}
var box = bootbox.dialog({
"message": 'It has been more than a few minutes since you last logged in, ' +
'so please verify your password to perform this sensitive operation:' +
'<form style="margin-top: 10px" action="javascript:$(\'.btn-continue\').click();void(0)">' +
'<input id="freshPassword" class="form-control" type="password" placeholder="Current Password">' +
'</form>',
"title": 'Please Verify',
"buttons": {
"verify": {
"label": "Verify",
"className": "btn-success btn-continue",
"callback": verifyNow
},
"close": {
"label": "Cancel",
"className": "btn-default",
"callback": function() {
reject('Verification canceled')
}
}
}
});
box.bind('shown.bs.modal', function(){
box.find("input").focus();
box.find("form").submit(function() {
if (!$('#freshPassword').val()) { return; }
box.modal('hide');
verifyNow();
});
});
// Return a new promise. We'll accept or reject it based on the result
// of the login.
return deferred.promise;
}
// Otherwise, we just 'raise' the error via the reject method on the promise.
return $q.reject(resp);
};
};
var buildMethodsForOperation = function(operation, method, path, resourceMap) {
var operationName = operation['operationId'];
var urlPath = path['x-path'];
// Add the operation itself.
apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forceget, opt_responseType) {
var one = Restangular.one(buildUrl(urlPath, opt_parameters));
if (opt_background || opt_responseType) {
let httpConfig = {};
if (opt_background) {
httpConfig['ignoreLoadingBar'] = true;
}
if (opt_responseType) {
httpConfig['responseType'] = opt_responseType;
}
one.withHttpConfig(httpConfig);
}
var opObj = one[opt_forceget ? 'get' : 'custom' + method.toUpperCase()](opt_options);
// If the operation requires_fresh_login, then add a specialized error handler that
// will defer the operation's result if sudo is requested.
if (operation['x-requires-fresh-login']) {
opObj = opObj.catch(freshLoginFailCheck(operationName, arguments));
}
return opObj;
};
// If the method for the operation is a GET, add an operationAsResource method.
if (method == 'get') {
apiService[operationName + 'AsResource'] = function(opt_parameters, opt_background) {
var getMethod = apiService[operationName];
return getResource(getMethod, operation, opt_parameters, opt_background);
};
}
// If the operation has a user-related operation, then make a generic operation for this operation
// that can call both the user and the organization versions of the operation, depending on the
// parameters given.
if (path['x-user-related']) {
var userOperationName = getMatchingUserOperationName(operationName, method, resourceMap[path['x-user-related']]);
var genericOperationName = getGenericOperationName(userOperationName);
apiService[genericOperationName] = function(orgname, opt_options, opt_parameters, opt_background) {
if (orgname) {
if (orgname.name) {
orgname = orgname.name;
}
var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background);
return apiService[operationName](opt_options, params);
} else {
return apiService[userOperationName](opt_options, opt_parameters, opt_background);
}
};
}
};
var allowedMethods = ['get', 'post', 'put', 'delete'];
var resourceMap = {};
var forEachOperation = function(callback) {
for (var path in window.__endpoints) {
if (!window.__endpoints.hasOwnProperty(path)) {
continue;
}
for (var method in window.__endpoints[path]) {
if (!window.__endpoints[path].hasOwnProperty(method)) {
continue;
}
if (allowedMethods.indexOf(method.toLowerCase()) < 0) { continue; }
callback(window.__endpoints[path][method], method, window.__endpoints[path]);
}
}
};
// Build the map of resource names to their objects.
forEachOperation(function(operation, method, path) {
resourceMap[path['x-name']] = path;
});
// Construct the methods for each API endpoint.
forEachOperation(function(operation, method, path) {
buildMethodsForOperation(operation, method, path, resourceMap);
});
apiService.getErrorMessage = function(resp, defaultMessage) {
var message = defaultMessage;
if (resp && resp['data']) {
//TODO: remove error_message and error_description (old style error)
message = resp['data']['detail'] || resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
}
return message;
};
apiService.errorDisplay = function(defaultMessage, opt_handler) {
return function(resp) {
var message = apiService.getErrorMessage(resp, defaultMessage);
if (opt_handler) {
var handlerMessage = opt_handler(resp);
if (handlerMessage) {
message = handlerMessage;
}
}
message = UtilService.stringToHTML(message);
bootbox.dialog({
"message": message,
"title": defaultMessage || 'Request Failure',
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
};
};
return apiService;
}]);

View file

@ -0,0 +1,43 @@
/**
* Helper service for working with the registry's container. Only works in enterprise.
*/
angular.module('quay-config')
.factory('ContainerService', ['ApiService', '$timeout', 'Restangular',
function(ApiService, $timeout, Restangular) {
var containerService = {};
containerService.restartContainer = function(callback) {
ApiService.errorDisplay('Removed Endpoint. This error should never be seen.')
};
containerService.scheduleStatusCheck = function(callback, opt_config) {
$timeout(function() {
containerService.checkStatus(callback, opt_config);
}, 2000);
};
containerService.checkStatus = function(callback, opt_config) {
var errorHandler = function(resp) {
if (resp.status == 404 || resp.status == 502 || resp.status == -1) {
// Container has not yet come back up, so we schedule another check.
containerService.scheduleStatusCheck(callback, opt_config);
return;
}
return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp);
};
// If config is specified, override the API base URL from this point onward.
// TODO: Find a better way than this. This is safe, since this will only be called
// for a restart, but it is still ugly.
if (opt_config && opt_config['SERVER_HOSTNAME']) {
var scheme = opt_config['PREFERRED_URL_SCHEME'] || 'http';
var baseUrl = scheme + '://' + opt_config['SERVER_HOSTNAME'] + '/api/v1/';
Restangular.setBaseUrl(baseUrl);
}
ApiService.scRegistryStatus(null, null, /* background */true)
.then(callback, errorHandler);
};
return containerService;
}]);

View file

@ -0,0 +1,23 @@
/**
* Helper service for working with cookies.
*/
angular.module('quay-config').factory('CookieService', ['$cookies', function($cookies) {
var cookieService = {};
cookieService.putPermanent = function(name, value) {
document.cookie = escape(name) + "=" + escape(value) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/";
};
cookieService.putSession = function(name, value) {
$cookies.put(name, value);
};
cookieService.clear = function(name) {
$cookies.remove(name);
};
cookieService.get = function(name) {
return $cookies.get(name);
};
return cookieService;
}]);

View file

@ -0,0 +1,60 @@
/**
* Helper service which fires off events when the document's visibility changes, as well as allowing
* other Angular code to query the state of the document's visibility directly.
*/
angular.module('quay-config').constant('CORE_EVENT', {
DOC_VISIBILITY_CHANGE: 'core.event.doc_visibility_change'
});
angular.module('quay-config').factory('DocumentVisibilityService', ['$rootScope', '$document', 'CORE_EVENT',
function($rootScope, $document, CORE_EVENT) {
var document = $document[0],
features,
detectedFeature;
function broadcastChangeEvent() {
$rootScope.$broadcast(CORE_EVENT.DOC_VISIBILITY_CHANGE,
document[detectedFeature.propertyName]);
}
features = {
standard: {
eventName: 'visibilitychange',
propertyName: 'hidden'
},
moz: {
eventName: 'mozvisibilitychange',
propertyName: 'mozHidden'
},
ms: {
eventName: 'msvisibilitychange',
propertyName: 'msHidden'
},
webkit: {
eventName: 'webkitvisibilitychange',
propertyName: 'webkitHidden'
}
};
Object.keys(features).some(function(feature) {
if (document[features[feature].propertyName] !== undefined) {
detectedFeature = features[feature];
return true;
}
});
if (detectedFeature) {
$document.on(detectedFeature.eventName, broadcastChangeEvent);
}
return {
/**
* Is the window currently hidden or not.
*/
isHidden: function() {
if (detectedFeature) {
return document[detectedFeature.propertyName];
}
}
};
}]);

View file

@ -0,0 +1,91 @@
/**
* Feature flags.
*/
angular.module('quay-config').factory('Features', [function() {
if (!window.__features) {
return {};
}
var features = window.__features;
features.getFeature = function(name, opt_defaultValue) {
var value = features[name];
if (value == null) {
return opt_defaultValue;
}
return value;
};
features.hasFeature = function(name) {
return !!features.getFeature(name);
};
features.matchesFeatures = function(list) {
for (var i = 0; i < list.length; ++i) {
var value = features.getFeature(list[i]);
if (!value) {
return false;
}
}
return true;
};
return features;
}]);
/**
* Application configuration.
*/
angular.module('quay-config').factory('Config', ['Features', function(Features) {
if (!window.__config) {
return {};
}
var config = window.__config;
config.getDomain = function() {
return config['SERVER_HOSTNAME'];
};
config.getHost = function(opt_auth) {
var auth = opt_auth;
if (auth) {
auth = auth + '@';
}
return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME'];
};
config.getHttp = function() {
return config['PREFERRED_URL_SCHEME'];
};
config.getUrl = function(opt_path) {
var path = opt_path || '';
return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path;
};
config.getValue = function(name, opt_defaultValue) {
var value = config[name];
if (value == null) {
return opt_defaultValue;
}
return value;
};
config.getEnterpriseLogo = function(opt_defaultValue) {
if (!config.ENTERPRISE_LOGO_URL) {
if (opt_defaultValue) {
return opt_defaultValue;
}
if (Features.BILLING) {
return '/static/img/quay-horizontal-color.svg';
} else {
return '/static/img/QuayEnterprise_horizontal_color.svg';
}
}
return config.ENTERPRISE_LOGO_URL;
};
return config;
}]);

View file

@ -0,0 +1,15 @@
export interface AngularPollChannel {
create: PollConstructor
}
type PollConstructor = (scope: MockAngularScope, requester: ShouldContinueCallback, opt_sleeptime?: number) => PollHandle;
type MockAngularScope = {
'$on': Function
};
type ShouldContinueCallback = (boolean) => void;
export interface PollHandle {
start(opt_skipFirstCall?: boolean): void,
stop(): void,
setSleepTime(sleepTime: number): void,
}

View file

@ -0,0 +1,177 @@
import * as Raven from 'raven-js';
/**
* Service which monitors the current user session and provides methods for returning information
* about the user.
*/
angular.module('quay-config')
.factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', '$location', '$timeout',
function(ApiService, CookieService, $rootScope, Config, $location, $timeout) {
var userResponse = {
verified: false,
anonymous: true,
username: null,
email: null,
organizations: [],
logins: [],
beforeload: true
};
var userService = {};
userService.hasEverLoggedIn = function() {
return CookieService.get('quay.loggedin') == 'true';
};
userService.updateUserIn = function(scope, opt_callback) {
scope.$watch(function () { return userService.currentUser(); }, function (currentUser) {
if (currentUser) {
$timeout(function(){
scope.user = currentUser;
if (opt_callback) {
opt_callback(currentUser);
}
}, 0, false);
};
}, true);
};
userService.load = function(opt_callback) {
var handleUserResponse = function(loadedUser) {
userResponse = loadedUser;
if (!userResponse.anonymous) {
if (Config.MIXPANEL_KEY) {
try {
mixpanel.identify(userResponse.username);
mixpanel.people.set({
'$email': userResponse.email,
'$username': userResponse.username,
'verified': userResponse.verified
});
mixpanel.people.set_once({
'$created': new Date()
})
} catch (e) {
window.console.log(e);
}
}
if (Config.MARKETO_MUNCHKIN_ID && userResponse['marketo_user_hash']) {
var associateLeadBody = {'Email': userResponse.email};
if (window.Munchkin !== undefined) {
try {
Munchkin.munchkinFunction(
'associateLead',
associateLeadBody,
userResponse['marketo_user_hash']
);
} catch (e) {
}
} else {
window.__quay_munchkin_queue.push([
'associateLead',
associateLeadBody,
userResponse['marketo_user_hash']
]);
}
}
if (window.Raven !== undefined) {
try {
Raven.setUser({
email: userResponse.email,
id: userResponse.username
});
} catch (e) {
window.console.log(e);
}
}
CookieService.putPermanent('quay.loggedin', 'true');
} else {
if (window.Raven !== undefined) {
Raven.setUser();
}
}
// If the loaded user has a prompt, redirect them to the update page.
if (loadedUser.prompts && loadedUser.prompts.length) {
$location.path('/updateuser');
return;
}
if (opt_callback) {
opt_callback(loadedUser);
}
};
ApiService.getLoggedInUser().then(function(loadedUser) {
handleUserResponse(loadedUser);
}, function() {
handleUserResponse({'anonymous': true});
});
};
userService.isOrganization = function(name) {
return !!userService.getOrganization(name);
};
userService.getOrganization = function(name) {
if (!userResponse || !userResponse.organizations) { return null; }
for (var i = 0; i < userResponse.organizations.length; ++i) {
var org = userResponse.organizations[i];
if (org.name == name) {
return org;
}
}
return null;
};
userService.isNamespaceAdmin = function(namespace) {
if (namespace == userResponse.username) {
return true;
}
var org = userService.getOrganization(namespace);
if (!org) {
return false;
}
return org.is_org_admin;
};
userService.isKnownNamespace = function(namespace) {
if (namespace == userResponse.username) {
return true;
}
var org = userService.getOrganization(namespace);
return !!org;
};
userService.getNamespace = function(namespace) {
var org = userService.getOrganization(namespace);
if (org) {
return org;
}
if (namespace == userResponse.username) {
return userResponse;
}
return null;
};
userService.currentUser = function() {
return userResponse;
};
// Update the user in the root scope.
userService.updateUserIn($rootScope);
return userService;
}]);

View file

@ -0,0 +1,83 @@
/**
* Service which exposes various utility methods.
*/
angular.module('quay-config').factory('UtilService', ['$sanitize',
function($sanitize) {
var utilService = {};
var adBlockEnabled = null;
utilService.isAdBlockEnabled = function(callback) {
if (adBlockEnabled !== null) {
callback(adBlockEnabled);
return;
}
if(typeof blockAdBlock === 'undefined') {
callback(true);
return;
}
var bab = new BlockAdBlock({
checkOnLoad: false,
resetOnEnd: true
});
bab.onDetected(function() { adBlockEnabled = true; callback(true); });
bab.onNotDetected(function() { adBlockEnabled = false; callback(false); });
bab.check();
};
utilService.isEmailAddress = function(val) {
var emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
return emailRegex.test(val);
};
utilService.escapeHtmlString = function(text) {
var textStr = (text || '').toString();
var adjusted = textStr.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return adjusted;
};
utilService.stringToHTML = function(text) {
text = utilService.escapeHtmlString(text);
text = text.replace(/\n/g, '<br>');
return text;
};
utilService.getRestUrl = function(args) {
var url = '';
for (var i = 0; i < arguments.length; ++i) {
if (i > 0) {
url += '/';
}
url += encodeURI(arguments[i])
}
return url;
};
utilService.textToSafeHtml = function(text) {
return $sanitize(utilService.escapeHtmlString(text));
};
return utilService;
}])
.factory('CoreDialog', [() => {
var service = {};
service['fatal'] = function(title, message) {
bootbox.dialog({
"title": title,
"message": "<div class='alert-icon-container-container'><div class='alert-icon-container'><div class='alert-icon'></div></div></div>" + message,
"buttons": {},
"className": "co-dialog fatal-error",
"closeButton": false
});
};
return service;
}]);

View file

@ -0,0 +1,319 @@
import * as URI from 'urijs';
const templateUrl = require('./setup.html');
(function() {
/**
* The Setup page provides a nice GUI walkthrough experience for setting up Red Hat Quay.
*/
angular.module('quay-config').directive('setup', () => {
const directiveDefinitionObject = {
priority: 1,
templateUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {
'isActive': '=isActive',
'configurationSaved': '&configurationSaved',
'setupCompleted': '&setupCompleted',
},
controller: SetupCtrl,
};
return directiveDefinitionObject;
})
function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) {
// if (!Features.SUPER_USERS) {
// return;
// }
$scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9_\.\-]+(:[0-9]+)?$';
$scope.validateHostname = function(hostname) {
if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) {
return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.'
}
return null;
};
// Note: The values of the enumeration are important for isStepFamily. For example,
// *all* states under the "configuring db" family must start with "config-db".
$scope.States = {
// Loading the state of the product.
'LOADING': 'loading',
// The configuration directory is missing.
'MISSING_CONFIG_DIR': 'missing-config-dir',
// The config.yaml exists but it is invalid.
'INVALID_CONFIG': 'config-invalid',
// DB is being configured.
'CONFIG_DB': 'config-db',
// DB information is being validated.
'VALIDATING_DB': 'config-db-validating',
// DB information is being saved to the config.
'SAVING_DB': 'config-db-saving',
// A validation error occurred with the database.
'DB_ERROR': 'config-db-error',
// Database is being setup.
'DB_SETUP': 'setup-db',
// An error occurred when setting up the database.
'DB_SETUP_ERROR': 'setup-db-error',
// A superuser is being configured.
'CREATE_SUPERUSER': 'create-superuser',
// The superuser is being created.
'CREATING_SUPERUSER': 'create-superuser-creating',
// An error occurred when setting up the superuser.
'SUPERUSER_ERROR': 'create-superuser-error',
// The superuser was created successfully.
'SUPERUSER_CREATED': 'create-superuser-created',
// General configuration is being setup.
'CONFIG': 'config',
// The configuration is fully valid.
'VALID_CONFIG': 'valid-config',
// The product is ready for use.
'READY': 'ready'
}
$scope.csrf_token = window.__token;
$scope.currentStep = $scope.States.LOADING;
$scope.errors = {};
$scope.stepProgress = [];
$scope.hasSSL = false;
$scope.hostname = null;
$scope.currentConfig = null;
$scope.currentState = {
'hasDatabaseSSLCert': false
};
$scope.$watch('currentStep', function(currentStep) {
$scope.stepProgress = $scope.getProgress(currentStep);
switch (currentStep) {
case $scope.States.CONFIG:
$('#setupModal').modal('hide');
break;
case $scope.States.MISSING_CONFIG_DIR:
$scope.showMissingConfigDialog();
break;
case $scope.States.INVALID_CONFIG:
$scope.showInvalidConfigDialog();
break;
case $scope.States.DB_SETUP:
$scope.performDatabaseSetup();
// Fall-through.
case $scope.States.CREATE_SUPERUSER:
case $scope.States.CONFIG_DB:
case $scope.States.VALID_CONFIG:
case $scope.States.READY:
$('#setupModal').modal({
keyboard: false,
backdrop: 'static'
});
break;
}
});
$scope.restartContainer = function(state) {
$scope.currentStep = state;
ContainerService.restartContainer(function() {
$scope.checkStatus()
});
};
$scope.showSuperuserPanel = function() {
$('#setupModal').modal('hide');
var prefix = $scope.hasSSL ? 'https' : 'http';
var hostname = $scope.hostname;
if (!hostname) {
hostname = document.location.hostname;
if (document.location.port) {
hostname = hostname + ':' + document.location.port;
}
}
window.location = prefix + '://' + hostname + '/superuser';
};
$scope.configurationSaved = function(config) {
$scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https';
$scope.hostname = config['SERVER_HOSTNAME'];
$scope.currentConfig = config;
$scope.currentStep = $scope.States.VALID_CONFIG;
};
$scope.getProgress = function(step) {
var isStep = $scope.isStep;
var isStepFamily = $scope.isStepFamily;
var States = $scope.States;
return [
isStepFamily(step, States.CONFIG_DB),
isStepFamily(step, States.DB_SETUP),
isStepFamily(step, States.CREATE_SUPERUSER),
isStep(step, States.CONFIG),
isStep(step, States.VALID_CONFIG),
isStep(step, States.READY)
];
};
$scope.isStepFamily = function(step, family) {
if (!step) { return false; }
return step.indexOf(family) == 0;
};
$scope.isStep = function(step) {
for (var i = 1; i < arguments.length; ++i) {
if (arguments[i] == step) {
return true;
}
}
return false;
};
$scope.beginSetup = function() {
$scope.currentStep = $scope.States.CONFIG_DB;
};
$scope.showInvalidConfigDialog = function() {
var message = "The <code>config.yaml</code> file found in <code>conf/stack</code> could not be parsed."
var title = "Invalid configuration file";
CoreDialog.fatal(title, message);
};
$scope.showMissingConfigDialog = function() {
var message = "A volume should be mounted into the container at <code>/conf/stack</code>: " +
"<br><br><pre>docker run -v /path/to/config:/conf/stack</pre>" +
"<br>Once fixed, restart the container. For more information, " +
"<a href='https://coreos.com/docs/enterprise-registry/initial-setup/'>" +
"Read the Setup Guide</a>"
var title = "Missing configuration volume";
CoreDialog.fatal(title, message);
};
$scope.parseDbUri = function(value) {
if (!value) { return null; }
// Format: mysql+pymysql://<username>:<url escaped password>@<hostname>/<database_name>
var uri = URI(value);
return {
'kind': uri.protocol(),
'username': uri.username(),
'password': uri.password(),
'server': uri.host(),
'database': uri.path() ? uri.path().substr(1) : ''
};
};
$scope.serializeDbUri = function(fields) {
if (!fields['server']) { return ''; }
if (!fields['database']) { return ''; }
var uri = URI();
try {
uri = uri && uri.host(fields['server']);
uri = uri && uri.protocol(fields['kind']);
uri = uri && uri.username(fields['username']);
uri = uri && uri.password(fields['password']);
uri = uri && uri.path('/' + (fields['database'] || ''));
uri = uri && uri.toString();
} catch (ex) {
return '';
}
return uri;
};
$scope.createSuperUser = function() {
$scope.currentStep = $scope.States.CREATING_SUPERUSER;
ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) {
$scope.checkStatus();
}, function(resp) {
$scope.currentStep = $scope.States.SUPERUSER_ERROR;
$scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser');
});
};
$scope.performDatabaseSetup = function() {
$scope.currentStep = $scope.States.DB_SETUP;
ApiService.scSetupDatabase(null, null).then(function(resp) {
if (resp['error']) {
$scope.currentStep = $scope.States.DB_SETUP_ERROR;
$scope.errors.DatabaseSetupError = resp['error'];
} else {
$scope.currentStep = $scope.States.CREATE_SUPERUSER;
}
}, ApiService.errorDisplay('Could not setup database. Please report this to support.'))
};
$scope.validateDatabase = function() {
$scope.currentStep = $scope.States.VALIDATING_DB;
$scope.databaseInvalid = null;
var data = {
'config': {
'DB_URI': $scope.databaseUri
},
};
if ($scope.currentState.hasDatabaseSSLCert) {
data['config']['DB_CONNECTION_ARGS'] = {
'ssl': {
'ca': 'conf/stack/database.pem'
}
};
}
var params = {
'service': 'database'
};
ApiService.scValidateConfig(data, params).then(function(resp) {
var status = resp.status;
if (status) {
$scope.currentStep = $scope.States.SAVING_DB;
ApiService.scUpdateConfig(data, null).then(function(resp) {
$scope.checkStatus();
}, ApiService.errorDisplay('Cannot update config. Please report this to support'));
} else {
$scope.currentStep = $scope.States.DB_ERROR;
$scope.errors.DatabaseValidationError = resp.reason;
}
}, ApiService.errorDisplay('Cannot validate database. Please report this to support'));
};
$scope.checkStatus = function() {
ContainerService.checkStatus(function(resp) {
$scope.currentStep = resp['status'];
}, $scope.currentConfig);
};
// Load the initial status.
$scope.checkStatus();
};
})();

View file

@ -0,0 +1,307 @@
<div id="padding-container">
<div>
<div class="cor-loader" ng-show="currentStep == States.LOADING"></div>
<div class="page-content" ng-show="currentStep == States.CONFIG">
<div class="cor-title">
<span class="cor-title-link"></span>
<span class="cor-title-content">Red Hat Quay Setup</span>
</div>
<div class="co-main-content-panel" style="padding: 20px;">
<div class="co-alert alert alert-info">
<span class="cor-step-bar" progress="stepProgress">
<span class="cor-step" title="Configure Database" text="1"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Setup Complete" icon="download"></span>
</span>
<div><strong>Almost done!</strong></div>
<div>Configure your Redis database and other settings below</div>
</div>
<div class="config-setup-tool" is-active="isStep(currentStep, States.CONFIG)"
configuration-saved="configurationSaved(config)"
setup-completed="setupCompleted()"
></div>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="co-dialog modal fade initial-setup-modal" id="setupModal">
<div class="modal-dialog">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<span class="cor-step-bar" progress="stepProgress">
<span class="cor-step" title="Configure Database" text="1"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Setup Complete" icon="download"></span>
</span>
<h4 class="modal-title">Setup</h4>
</div>
<form id="superuserForm" name="superuserForm" ng-submit="createSuperUser()">
<!-- Content: CREATE_SUPERUSER or SUPERUSER_ERROR or CREATING_SUPERUSER -->
<div class="modal-body config-setup-tool-element" style="padding: 20px"
ng-show="isStep(currentStep, States.CREATE_SUPERUSER, States.SUPERUSER_ERROR, States.CREATING_SUPERUSER)">
<p>A superuser is the main administrator of your <span class="registry-name" is-short="true"></span>. Only superusers can edit configuration settings.</p>
<div class="form-group">
<label>Username</label>
<input class="form-control" type="text" ng-model="superUser.username"
ng-pattern="/^[a-z0-9_]{4,30}$/" required>
<div class="help-text">Minimum 4 characters in length</div>
</div>
<div class="form-group">
<label>Email address</label>
<input class="form-control" type="email" ng-model="superUser.email" required>
</div>
<div class="form-group">
<label>Password</label>
<input class="form-control" type="password" ng-model="superUser.password"
ng-pattern="/^[^\s]+$/"
ng-minlength="8" required>
<div class="help-text">Minimum 8 characters in length</div>
</div>
<div class="form-group">
<label>Repeat Password</label>
<input class="form-control" type="password" ng-model="superUser.repeatPassword"
match="superUser.password" required>
</div>
</div>
<!-- Footer: CREATE_SUPERUSER or SUPERUSER_ERROR -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.CREATE_SUPERUSER, States.SUPERUSER_ERROR)">
<button type="submit" class="btn btn-primary" ng-disabled="!superuserForm.$valid">
Create Super User
</button>
</div>
</form>
<!-- Content: DB_RESTARTING or CONFIG_RESTARTING -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_RESTARTING, States.CONFIG_RESTARTING)">
<h4 style="margin-bottom: 20px;">
<i class="fa fa-lg fa-sync" style="margin-right: 10px;"></i>
<span class="registry-name"></span> is currently being restarted
</h4>
This can take several minutes. If the container does not restart on its own,
please re-execute the <code>docker run</code> command.
</div>
<!-- Content: READY -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.READY)">
<h4>Installation and setup of <span class="registry-name"></span> is complete</h4>
You can now invite users to join, create organizations and start pushing and pulling
repositories.
<strong ng-if="hasSSL" style="margin-top: 20px;">
Note: SSL is enabled. Please make sure to visit with
an <u>https</u> prefix
</strong>
</div>
<!-- Content: VALID_CONFIG -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.VALID_CONFIG)">
<h4>All configuration has been validated and saved</h4>
The container must be restarted to apply the configuration changes.
</div>
<!-- Content: DB_SETUP_SUCCESS -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_SETUP_SUCCESS)">
<h4>The database has been setup and is ready</h4>
The container must be restarted to apply the configuration changes.
</div>
<!-- Content: DB_SETUP or DB_SETUP_ERROR -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_SETUP, States.DB_SETUP_ERROR)">
<h4>
<i class="fa fa-lg fa-database" style="margin-right: 10px;"></i>
<span class="registry-name"></span> is currently setting up its database
schema
</h4>
This can take several minutes.
</div>
<!-- Content: CONFIG_DB or DB_ERROR or VALIDATING_DB or SAVING_DB -->
<div class="modal-body validate-database config-setup-tool-element"
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR, States.VALIDATING_DB, States.SAVING_DB)">
<p>
Please enter the connection details for your <strong>empty</strong> database. The schema will be created in the following step.</p>
</p>
<div class="config-parsed-field" binding="databaseUri"
parser="parseDbUri(value)"
serializer="serializeDbUri(fields)">
<table class="config-table">
<tr>
<td class="non-input">Database Type:</td>
<td>
<select ng-model="fields.kind">
<option value="mysql+pymysql">MySQL</option>
<option value="postgresql">Postgres</option>
</select>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Database Server:</td>
<td>
<span class="config-string-field" binding="fields.server"
placeholder="dbserverhost"
pattern="{{ HOSTNAME_REGEX }}"
validator="validateHostname(value)">></span>
<div class="help-text">
The server (and optionally, custom port) where the database lives
</div>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Username:</td>
<td>
<span class="config-string-field" binding="fields.username"
placeholder="someuser"></span>
<div class="help-text">This user must have <strong>full access</strong> to the database</div>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Password:</td>
<td>
<input class="form-control" type="password" ng-model="fields.password"></span>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Database Name:</td>
<td>
<span class="config-string-field" binding="fields.database"
placeholder="registry-database"></span>
</td>
</tr>
<tr ng-show="fields.kind">
<td>SSL Certificate:</td>
<td>
<span class="config-file-field" filename="database.pem"
skip-check-file="true" has-file="currentState.hasDatabaseSSLCert"></span>
<div class="help-text">Optional SSL certicate (in PEM format) to use to connect to the database</div>
</td>
</tr>
</table>
</div>
</div>
<!-- Footer: CREATING_SUPERUSER -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.CREATING_SUPERUSER)">
<span class="cor-loader-inline"></span> Creating superuser...
</div>
<!-- Footer: SUPERUSER_ERROR -->
<div class="modal-footer alert alert-danger"
ng-show="isStep(currentStep, States.SUPERUSER_ERROR)">
{{ errors.SuperuserCreationError }}
</div>
<!-- Footer: DB_SETUP_ERROR -->
<div class="modal-footer alert alert-danger"
ng-show="isStep(currentStep, States.DB_SETUP_ERROR)">
Database Setup Failed: {{ errors.DatabaseSetupError }}
</div>
<!-- Footer: DB_ERROR -->
<div class="modal-footer alert alert-danger" ng-show="isStep(currentStep, States.DB_ERROR)">
Database Validation Issue: {{ errors.DatabaseValidationError }}
</div>
<!-- Footer: CONFIG_DB or DB_ERROR -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR)">
<span class="left-align" ng-show="isStep(currentStep, States.DB_ERROR)">
<i class="fa fa-warning"></i>
Problem Detected
</span>
<button type="submit" class="btn btn-primary"
ng-disabled="!databaseUri"
ng-click="validateDatabase()">
Validate Database Settings
</button>
</div>
<!-- Footer: READY -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.READY)">
<span class="left-align">
<i class="fa fa-check"></i>
Installation Complete!
</span>
<a ng-click="showSuperuserPanel()" class="btn btn-primary">
View Superuser Panel
</a>
</div>
<!-- Footer: VALID_CONFIG -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.VALID_CONFIG)">
<span class="left-align">
<i class="fa fa-check"></i>
Configuration Validated and Saved
</span>
<button type="submit" class="btn btn-primary"
ng-click="restartContainer(States.CONFIG_RESTARTING)">
Restart Container
</button>
</div>
<!-- Footer: DB_SETUP_SUCCESS -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.DB_SETUP_SUCCESS)">
<span class="left-align">
<i class="fa fa-check"></i>
Database Setup and Ready
</span>
<button type="submit" class="btn btn-primary"
ng-click="restartContainer(States.DB_RESTARTING)">
Restart Container
</button>
</div>
<!-- Footer: DB_SETUP -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.DB_SETUP)">
<span class="cor-loader-inline"></span> Setting up database...
</div>
<!-- Footer: SAVING_DB -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.SAVING_DB)">
<span class="cor-loader-inline"></span> Saving database configuration...
</div>
<!-- Footer: VALIDATING_DB -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.VALIDATING_DB)">
<span class="cor-loader-inline"></span> Testing database settings...
</div>
<!-- Footer: DB_RESTARTING or CONFIG_RESTARTING-->
<div class="modal-footer working"
ng-show="isStep(currentStep, States.DB_RESTARTING, States.CONFIG_RESTARTING)">
<span class="cor-loader-inline"></span> Waiting for container to restart...
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>