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;
});