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,20 @@
/**
* An element which displays its contents wrapped in an <a> tag, but only if the href is not null.
*/
angular.module('quay').directive('anchor', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/anchor.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'href': '@href',
'target': '@target',
'isOnlyText': '=isOnlyText'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,138 @@
<div class="app-public-view-element">
<div class="co-main-content-panel">
<div class="app-row">
<!-- Main panel -->
<div class="col-md-9 col-sm-12 main-content">
<!-- App Header -->
<div class="app-header">
<a href="https://coreos.com/blog/quay-application-registry-for-kubernetes.html" class="hidden-xs hidden-sm" style="float: right; padding: 6px;" ng-safenewtab><i class="fa fa-info-circle" style="margin-right: 6px;"></i>Learn more about applications</a>
<h3><i class="fa ci-appcube"></i>{{ $ctrl.repository.namespace }}/{{ $ctrl.repository.name }}</h3>
</div>
<!-- Tabs -->
<cor-tab-panel cor-nav-tabs>
<cor-tabs>
<cor-tab tab-title="Description" tab-id="description">
<i class="fa fa-info-circle"></i>
</cor-tab>
<cor-tab tab-title="Channels" tab-id="channels">
<i class="fa fa-tags"></i>
</cor-tab>
<cor-tab tab-title="Releases" tab-id="releases">
<i class="fa ci-package"></i>
</cor-tab>
<cor-tab tab-title="Usage Logs" tab-id="logs" tab-init="$ctrl.showLogs()" ng-if="$ctrl.repository.can_admin">
<i class="fa fa-bar-chart"></i>
</cor-tab>
<cor-tab tab-title="Settings" tab-id="settings" tab-init="$ctrl.showSettings()" ng-if="$ctrl.repository.can_admin">
<i class="fa fa-gear"></i>
</cor-tab>
</cor-tabs>
<cor-tab-content>
<!-- Description -->
<cor-tab-pane id="description">
<div class="description">
<markdown-input content="$ctrl.repository.description"
can-write="$ctrl.repository.can_write"
(content-changed)="$ctrl.updateDescription($event.content)"
field-title="repository description"></markdown-input>
</div>
</cor-tab-pane>
<!-- Channels -->
<cor-tab-pane id="channels">
<div ng-show="!$ctrl.repository.channels.length && $ctrl.repository.can_write">
<h3>No channels found for this application</h3>
<br>
<p class="hidden-xs">
To push a new channel (from within the Helm package directory and with the <a href="https://github.com/app-registry/appr-helm-plugin" ng-safenewtab>Helm registry plugin</a> installed):
<pre class="command hidden-xs">helm registry push --namespace {{ $ctrl.repository.namespace }} --channel {channelName} {{ $ctrl.Config.SERVER_HOSTNAME }}</pre>
</p>
</div>
<div ng-show="$ctrl.repository.channels.length || !$ctrl.repository.can_write">
<cor-table table-data="$ctrl.repository.channels" table-item-title="channels" filter-fields="['name']">
<cor-table-col datafield="name" sortfield="name" title="Name"
templateurl="/static/js/directives/ui/app-public-view/channel-name.html"></cor-table-col>
<cor-table-col datafield="release" sortfield="release" title="Current Release"></cor-table-col>
<cor-table-col datafield="last_modified" sortfield="last_modified" title="Last Modified"
selected="true" kindof="datetime"
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
</cor-table>
</div>
</cor-tab-pane>
<!-- Releases -->
<cor-tab-pane id="releases">
<div ng-show="!$ctrl.repository.releases.length && $ctrl.repository.can_write">
<h3>No releases found for this application</h3>
<br>
<p class="hidden-xs">
To push a new release (from within the Helm package directory and with the <a href="https://coreos.com/apps" ng-safenewtab>Helm registry plugin</a> installed):
<pre class="command hidden-xs">helm registry push --namespace {{ $ctrl.repository.namespace }} {{ $ctrl.Config.SERVER_HOSTNAME }}</pre>
</p>
</div>
<div ng-show="$ctrl.repository.releases.length || !$ctrl.repository.can_write">
<cor-table table-data="$ctrl.repository.releases"
table-item-title="releases"
filter-fields="['name']"
can-expand="true">
<cor-table-col datafield="name" sortfield="name" title="Name"></cor-table-col>
<cor-table-col datafield="last_modified" sortfield="last_modified"
title="Created"
selected="true" kindof="datetime"
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
<cor-table-col datafield="channels" title="Channels" item-limit="6"
templateurl="/static/js/directives/ui/app-public-view/channels-list.html"></cor-table-col>
</cor-table>
</div>
</cor-tab-pane>
<!-- Usage Logs-->
<cor-tab-pane id="logs" ng-if="$ctrl.repository.can_admin">
<div class="logs-view" repository="$ctrl.repository" makevisible="$ctrl.logsShown"></div>
</cor-tab-pane>
<!-- Settings -->
<cor-tab-pane id="settings" ng-if="$ctrl.repository.can_admin">
<div class="repo-panel-settings" repository="$ctrl.repository" is-enabled="$ctrl.settingsShown"></div>
</cor-tab-pane>
</cor-tab-content>
</cor-tab-panel>
</div>
<!-- Side bar -->
<div class="col-md-3 hidden-xs hidden-sm side-bar">
<div>
<visibility-indicator repository="$ctrl.repository"></visibility-indicator>
</div>
<div ng-if="$ctrl.repository.is_public">{{ $ctrl.repository.namespace }} is sharing this application publicly</div>
<div ng-if="!$ctrl.repository.is_public">This application is private and only visible to those with permission</div>
<div class="sidebar-table" ng-if="$ctrl.repository.channels.length">
<h4>Latest Channels</h4>
<cor-table table-data="$ctrl.repository.channels" table-item-title="channels" filter-fields="['name']" compact="true" max-display-count="3">
<cor-table-col datafield="name" sortfield="name" title="Name"
templateurl="/static/js/directives/ui/app-public-view/channel-name.html"></cor-table-col>
<cor-table-col style="word-wrap: break-word;"
datafield="release" sortfield="release" title="Current Release"></cor-table-col>
</cor-table>
</div>
<div class="sidebar-table" ng-if="$ctrl.repository.releases.length">
<h4>Latest Releases</h4>
<cor-table table-data="$ctrl.repository.releases" table-item-title="releases" filter-fields="['name']" compact="true" max-display-count="3">
<cor-table-col style="word-wrap: break-word;"
datafield="name" sortfield="name" title="Name"></cor-table-col>
<cor-table-col datafield="last_modified" sortfield="last_modified"
title="Created"
selected="true" kindof="datetime"
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
</cor-table>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,34 @@
import { Input, Component, Inject } from 'ng-metadata/core';
/**
* A component that displays the public information associated with an application repository.
*/
@Component({
selector: 'app-public-view',
templateUrl: '/static/js/directives/ui/app-public-view/app-public-view.component.html'
})
export class AppPublicViewComponent {
@Input('<') public repository: any;
private settingsShown: number = 0;
private logsShown: number = 0;
constructor(@Inject('Config') private Config: any) {
this.updateDescription = this.updateDescription.bind(this);
}
public showSettings(): void {
this.settingsShown++;
}
public showLogs(): void {
this.logsShown++;
}
private updateDescription(content: string) {
this.repository.description = content;
this.repository.put();
}
}

View file

@ -0,0 +1 @@
<channel-icon name="item.name"></channel-icon><span style="vertical-align: middle; margin-left: 6px;">{{ item.name }}</span>

View file

@ -0,0 +1,19 @@
<div style="display: flex; align-items: center;">
<div style="display: flex; flex-wrap: wrap; width: 70%;">
<!-- TODO: Move repeat logic to separate component -->
<span ng-if="item.channels.length > 0"
ng-repeat="channel_name in item.channels | limitTo : ($ctrl.rows[rowIndex].expanded ? item.channels.length : col.itemLimit)"
ng-style="{
'width': (100 / col.itemLimit) + '%',
'margin-bottom': $ctrl.rows[rowIndex].expanded && $index < (item.channels.length - col.itemLimit) ? '5px' : ''
}">
<channel-icon name="channel_name"></channel-icon>
</span>
</div>
<a ng-if="item.channels.length > col.itemLimit"
ng-click="$ctrl.rows[rowIndex].expanded = !$ctrl.rows[rowIndex].expanded">
{{ $ctrl.rows[rowIndex].expanded ? 'show less...' : item.channels.length - col.itemLimit + ' more...' }}
</a>
<span ng-if="!item.channels.length" class="empty">(None)</span>
</div>

View file

@ -0,0 +1 @@
<time-ago datetime="item.last_modified"></time-ago>

View file

@ -0,0 +1,33 @@
<div class="resource-view" resource="$ctrl.appTokensResource">
<div style="float: right; margin-left: 10px;" ng-show="!$ctrl.inReadOnlyMode">
<button class="btn btn-primary" ng-click="$ctrl.askCreateToken()">Create Application Token</button>
</div>
<cor-table table-data="$ctrl.appTokens" table-item-title="tokens" filter-fields="['title']">
<cor-table-col datafield="title" sortfield="title" title="Title" selected="true"
bind-model="$ctrl"
templateurl="/static/js/directives/ui/app-specific-token-manager/token-title.html"></cor-table-col>
<cor-table-col datafield="last_accessed" sortfield="last_accessed" title="Last Accessed"
kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/last-accessed.html"></cor-table-col>
<cor-table-col datafield="expiration" sortfield="expiration" title="Expiration"
kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/expiration.html"></cor-table-col>
<cor-table-col datafield="created" sortfield="created" title="Created"
kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/created.html"></cor-table-col>
<cor-table-col templateurl="/static/js/directives/ui/app-specific-token-manager/cog.html"
bind-model="$ctrl" class="options-col" ng-if="!$ctrl.inReadOnlyMode"></cor-table-col>
</cor-table>
<div class="credentials-dialog" credentials="$ctrl.tokenCredentials" secret-title="Application Token" entity-title="application token" entity-icon="fa-key"></div>
<!-- Revoke token confirm -->
<div class="cor-confirm-dialog"
dialog-context="$ctrl.revokeTokenInfo"
dialog-action="$ctrl.revokeToken(info.token, callback)"
dialog-title="Revoke Application Token"
dialog-action-title="Revoke Token">
<div class="co-alert co-alert-warning" style="margin-bottom: 10px;">
Application token "{{ $ctrl.revokeTokenInfo.token.title }}" will be revoked and <strong>all</strong> applications and CLIs making use of the token will no longer operate.
</div>
Proceed with revocation of this token?
</div>
</div>

View file

@ -0,0 +1,78 @@
import { Input, Component, Inject } from 'ng-metadata/core';
import * as bootbox from "bootbox";
/**
* A component that displays and manage all app specific tokens for a user.
*/
@Component({
selector: 'app-specific-token-manager',
templateUrl: '/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html',
})
export class AppSpecificTokenManagerComponent {
private appTokensResource: any;
private appTokens: Array<any>;
private tokenCredentials: any;
private revokeTokenInfo: any;
private inReadOnlyMode: boolean;
constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any,
@Inject('NotificationService') private NotificationService: any,
@Inject('StateService') private StateService: any) {
this.loadTokens();
this.inReadOnlyMode = StateService.inReadOnlyMode();
}
private loadTokens() {
this.appTokensResource = this.ApiService.listAppTokensAsResource().get((resp) => {
this.appTokens = resp['tokens'];
});
}
private askCreateToken() {
bootbox.prompt('Please enter a descriptive title for the new application token', (title) => {
if (!title) { return; }
const errorHandler = this.ApiService.errorDisplay('Could not create the application token');
this.ApiService.createAppToken({title}).then((resp) => {
this.loadTokens();
}, errorHandler);
});
}
private showRevokeToken(token) {
this.revokeTokenInfo = {
'token': token,
};
};
private revokeToken(token, callback) {
const errorHandler = this.ApiService.errorDisplay('Could not revoke application token', callback);
const params = {
'token_uuid': token['uuid'],
};
this.ApiService.revokeAppToken(null, params).then((resp) => {
this.loadTokens();
// Update the notification service so it hides any banners if we revoked an expiring token.
this.NotificationService.update();
callback(true);
}, errorHandler);
}
private showToken(token) {
const errorHandler = this.ApiService.errorDisplay('Could not find application token');
const params = {
'token_uuid': token['uuid'],
};
this.ApiService.getAppToken(null, params).then((resp) => {
this.tokenCredentials = {
'title': resp['token']['title'],
'namespace': this.UserService.currentUser().username,
'username': '$app',
'password': resp['token']['token_code'],
};
}, errorHandler);
}
}

View file

@ -0,0 +1,5 @@
<span class="cor-options-menu">
<span class="cor-option" option-click="col.bindModel.showRevokeToken(item)">
<i class="fa fa-times"></i> Revoke Token
</span>
</span>

View file

@ -0,0 +1 @@
<time-ago datetime="item.created"></time-ago>

View file

@ -0,0 +1 @@
<expiration-status-view expiration-date="item.expiration"></expiration-status-view>

View file

@ -0,0 +1 @@
<time-ago datetime="item.last_accessed"></time-ago>

View file

@ -0,0 +1 @@
<a ng-click="col.bindModel.showToken(item)">{{ item.title }}</a>

View file

@ -0,0 +1,18 @@
/**
* An element which shows information about a registered OAuth application.
*/
angular.module('quay').directive('applicationInfo', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/application-info.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'application': '=application'
},
controller: function($scope, $element, ApiService) {}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,66 @@
/**
* Element for managing the applications of an organization.
*/
angular.module('quay').directive('applicationManager', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/application-manager.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'organization': '=organization',
'makevisible': '=makevisible'
},
controller: function($scope, $element, ApiService) {
$scope.loading = false;
$scope.applications = [];
$scope.feedback = null;
$scope.createApplication = function(appName) {
if (!appName) { return; }
var params = {
'orgname': $scope.organization.name
};
var data = {
'name': appName
};
ApiService.createOrganizationApplication(data, params).then(function(resp) {
$scope.applications.push(resp);
$scope.feedback = {
'kind': 'success',
'message': 'Application {application_name} created',
'data': {
'application_name': appName
}
};
}, ApiService.errorDisplay('Cannot create application'));
};
var update = function() {
if (!$scope.organization || !$scope.makevisible) { return; }
if ($scope.loading) { return; }
$scope.loading = true;
var params = {
'orgname': $scope.organization.name
};
ApiService.getOrganizationApplications(null, params).then(function(resp) {
$scope.loading = false;
$scope.applications = resp['applications'] || [];
});
};
$scope.$watch('organization', update);
$scope.$watch('makevisible', update);
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,36 @@
/**
* An element which shows information about an OAuth application and provides a clickable link
* for displaying a dialog with further information. Unlike application-info, this element is
* intended for the *owner* of the application (since it requires the client ID).
*/
angular.module('quay').directive('applicationReference', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/application-reference.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'title': '=title',
'clientId': '=clientId'
},
controller: function($scope, $element, ApiService, $modal) {
$scope.showAppDetails = function() {
var params = {
'client_id': $scope.clientId
};
ApiService.getApplicationInformation(null, params).then(function(resp) {
$scope.applicationInfo = resp;
$modal({
title: 'Application Information',
scope: $scope,
template: '/static/directives/application-reference-dialog.html',
show: true
});
}, ApiService.errorDisplay('Application could not be found'));
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,41 @@
/**
* Element for managing the applications authorized by a user.
*/
angular.module('quay').directive('authorizedAppsManager', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/authorized-apps-manager.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'user': '=user',
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, ApiService) {
$scope.$watch('isEnabled', function(enabled) {
if (!enabled) { return; }
loadAuthedApps();
});
var loadAuthedApps = function() {
if ($scope.authorizedAppsResource) { return; }
$scope.authorizedAppsResource = ApiService.listUserAuthorizationsAsResource().get(function(resp) {
$scope.authorizedApps = resp['authorizations'];
});
};
$scope.deleteAccess = function(accessTokenInfo) {
var params = {
'access_token_uuid': accessTokenInfo['uuid']
};
ApiService.deleteUserAuthorization(null, params).then(function(resp) {
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
}, ApiService.errorDisplay('Could not revoke authorization'));
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,65 @@
/**
* An element which displays an avatar for the given avatar data.
*/
angular.module('quay').directive('avatar', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/avatar.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'data': '=data',
'size': '=size'
},
controller: function($scope, $element, AvatarService, Config, UIService, $timeout) {
$scope.AvatarService = AvatarService;
$scope.Config = Config;
$scope.isLoading = true;
$scope.showGravatar = false;
$scope.loadGravatar = false;
$scope.imageCallback = function(result) {
$scope.isLoading = false;
if (!result) {
$scope.showGravatar = false;
return;
}
// Determine whether the gravatar is blank.
var canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
var ctx = canvas.getContext("2d");
ctx.drawImage($element.find('img')[0], 0, 0);
var blank = document.createElement("canvas");
blank.width = 512;
blank.height = 512;
var isBlank = canvas.toDataURL('text/png') == blank.toDataURL('text/png');
$scope.showGravatar = !isBlank;
};
$scope.$watch('size', function(size) {
size = size * 1 || 16;
$scope.fontSize = (size - 4) + 'px';
$scope.lineHeight = size + 'px';
$scope.imageSize = size;
});
$scope.$watch('data', function(data) {
if (!data) { return; }
$scope.loadGravatar = Config.AVATAR_KIND == 'gravatar' &&
(data.kind == 'user' || data.kind == 'org');
$scope.isLoading = $scope.loadGravatar;
$scope.hasGravatar = false;
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,97 @@
/**
* Element for displaying the list of billing invoices for the user or organization.
*/
angular.module('quay').directive('billingInvoices', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/billing-invoices.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'organization': '=organization',
'user': '=user',
'makevisible': '=makevisible'
},
controller: function($scope, $element, $sce, ApiService) {
$scope.loading = false;
$scope.showCreateField = null;
$scope.invoiceFields = [];
var update = function() {
var hasValidUser = !!$scope.user;
var hasValidOrg = !!$scope.organization;
var isValid = hasValidUser || hasValidOrg;
if (!$scope.makevisible || !isValid) {
return;
}
$scope.loading = true;
ApiService.listInvoices($scope.organization).then(function(resp) {
$scope.invoices = resp.invoices;
$scope.loading = false;
}, function() {
$scope.invoices = [];
$scope.loading = false;
});
ApiService.listInvoiceFields($scope.organization).then(function(resp) {
$scope.invoiceFields = resp.fields || [];
}, function() {
$scope.invoiceFields = [];
});
};
$scope.$watch('organization', update);
$scope.$watch('user', update);
$scope.$watch('makevisible', update);
$scope.showCreateField = function() {
$scope.createFieldInfo = {
'title': '',
'value': ''
};
};
$scope.askDeleteField = function(field) {
bootbox.confirm('Are you sure you want to delete field ' + field.title + '?', function(r) {
if (r) {
var params = {
'field_uuid': field.uuid
};
ApiService.deleteInvoiceField($scope.organization, null, params).then(function(resp) {
$scope.invoiceFields = $.grep($scope.invoiceFields, function(current) {
return current.uuid != field.uuid
});
}, ApiService.errorDisplay('Could not delete custom field'));
}
});
};
$scope.createCustomField = function(title, value, callback) {
var data = {
'title': title,
'value': value
};
if (!title || !value) {
callback(false);
bootbox.alert('Missing title or value');
return;
}
ApiService.createInvoiceField($scope.organization, data).then(function(resp) {
$scope.invoiceFields.push(resp);
callback(true);
}, ApiService.errorDisplay('Could not create custom field'));
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,144 @@
/**
* An element which displays the billing options for a user or an organization.
*/
angular.module('quay').directive('billingManagementPanel', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/billing-management-panel.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'user': '=user',
'organization': '=organization',
'isEnabled': '=isEnabled',
'subscriptionStatus': '=subscriptionStatus'
},
controller: function($scope, $element, PlanService, ApiService, Features) {
$scope.currentCard = null;
$scope.subscription = null;
$scope.updating = true;
$scope.changeReceiptsInfo = null;
$scope.context = {};
$scope.subscriptionStatus = 'loading';
var setSubscription = function(sub) {
$scope.subscription = sub;
// Load the plan info.
PlanService.getPlanIncludingDeprecated(sub['plan'], function(plan) {
$scope.currentPlan = plan;
if (!sub.hasSubscription) {
$scope.updating = false;
$scope.subscriptionStatus = 'none';
return;
}
// Load credit card information.
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
$scope.currentCard = card;
$scope.subscriptionStatus = 'valid';
$scope.updating = false;
});
});
};
var update = function() {
if (!$scope.isEnabled || !($scope.user || $scope.organization) || !Features.BILLING) {
return;
}
$scope.entity = $scope.user ? $scope.user : $scope.organization;
$scope.invoice_email = $scope.entity.invoice_email;
$scope.invoice_email_address = $scope.entity.invoice_email_address || $scope.entity.email;
$scope.updating = true;
// Load plan information.
PlanService.getSubscription($scope.organization, setSubscription, function() {
setSubscription({ 'plan': PlanService.getFreePlan() });
});
};
// Listen to plan changes.
PlanService.registerListener(this, function(plan) {
if (plan && plan.price > 0) {
update();
}
});
$scope.$on('$destroy', function() {
PlanService.unregisterListener(this);
});
$scope.$watch('isEnabled', update);
$scope.$watch('organization', update);
$scope.$watch('user', update);
$scope.getEntityPrefix = function() {
if ($scope.organization) {
return '/organization/' + $scope.organization.name;
} else {
return '/user/' + $scope.user.username;
}
};
$scope.changeCreditCard = function() {
var callbacks = {
'opened': function() { },
'closed': function() { },
'started': function() { },
'success': function(resp) {
$scope.currentCard = resp.card;
update();
},
'failure': function(resp) {
if (!PlanService.isCardError(resp)) {
bootbox.alert('Could not change credit card. Please try again later.');
}
}
};
PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks);
};
$scope.getCreditImage = function(creditInfo) {
if (!creditInfo || !creditInfo.type) { return 'credit.png'; }
var kind = creditInfo.type.toLowerCase() || 'credit';
var supported = {
'american express': 'amex',
'credit': 'credit',
'diners club': 'diners',
'discover': 'discover',
'jcb': 'jcb',
'mastercard': 'mastercard',
'visa': 'visa'
};
kind = supported[kind] || 'credit';
return kind + '.png';
};
$scope.changeReceipts = function(info, callback) {
$scope.entity['invoice_email'] = info['sendOption'] || false;
$scope.entity['invoice_email_address'] = info['address'] || $scope.entity.email;
var errorHandler = ApiService.errorDisplay('Could not change billing options', callback);
ApiService.changeDetails($scope.organization, $scope.entity).then(function(resp) {
callback(true);
update();
}, errorHandler);
};
$scope.showChangeReceipts = function() {
$scope.changeReceiptsInfo = {
'sendOption': $scope.invoice_email,
'address': $scope.invoice_email_address
};
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,20 @@
/**
* An element which displays the status of a build in a nice compact bar.
*/
angular.module('quay').directive('buildInfoBar', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-info-bar.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'build': '=build',
'showTime': '=showTime',
'hideId': '=hideId'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,49 @@
/**
* An element which displays a command in a build.
*/
angular.module('quay').directive('buildLogCommand', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-log-command.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'command': '<command'
},
controller: function($scope, $element) {
$scope.getWithoutStep = function(fullTitle) {
var colon = fullTitle.indexOf(':');
if (colon <= 0) {
return '';
}
return $.trim(fullTitle.substring(colon + 1));
};
$scope.isSecondaryFrom = function(fullTitle) {
if (!fullTitle) { return false; }
var command = $scope.getWithoutStep(fullTitle);
return command.indexOf('FROM ') == 0 && fullTitle.indexOf('Step 1 ') < 0;
};
$scope.fromName = function(fullTitle) {
var command = $scope.getWithoutStep(fullTitle);
if (command.indexOf('FROM ') != 0) {
return null;
}
var parts = command.split(' ');
for (var i = 0; i < parts.length - 1; i++) {
var part = parts[i];
if ($.trim(part) == 'as') {
return parts[i + 1];
}
}
return null;
}
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,71 @@
/**
* An element which displays a build error in a nice format.
*/
angular.module('quay').directive('buildLogError', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-log-error.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'error': '=error',
'entries': '=entries',
'isSuperuser': '<isSuperuser'
},
controller: function($scope, $element, Config) {
$scope.localPullInfo = null;
var calculateLocalPullInfo = function(entries) {
var localInfo = {
'isLocal': false
};
// Find the 'pulling' phase entry, and then extra any metadata found under
// it.
for (var i = 0; i < $scope.entries.length; ++i) {
var entry = $scope.entries[i];
if (entry.type == 'phase' && entry.message == 'pulling') {
var entryData = entry.data || {};
if (entryData.base_image) {
localInfo['isLocal'] = true || entryData['base_image'].indexOf(Config.SERVER_HOSTNAME + '/') == 0;
localInfo['pullUsername'] = entryData['pull_username'];
localInfo['repo'] = entryData['base_image'].substring(Config.SERVER_HOSTNAME.length);
}
break;
}
}
$scope.localPullInfo = localInfo;
};
calculateLocalPullInfo($scope.entries);
$scope.getInternalError = function(entries) {
var entry = entries[entries.length - 1];
if (entry && entry.data && entry.data['internal_error']) {
return entry.data['internal_error'];
}
return null;
};
$scope.getBaseError = function(error) {
if (!error || !error.data || !error.data.base_error) {
return false;
}
return error.data.base_error;
};
$scope.isPullError = function(error) {
if (!error || !error.data || !error.data.base_error) {
return false;
}
return error.data.base_error.indexOf('Error: Status 403 trying to pull repository ') == 0;
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,18 @@
/**
* An element which displays the phase of a build nicely.
*/
angular.module('quay').directive('buildLogPhase', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-log-phase.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'phase': '=phase'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,215 @@
/**
* An element which displays and auto-updates the logs from a build.
*/
angular.module('quay').directive('buildLogsView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-logs-view.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'build': '=build',
'useTimestamps': '=useTimestamps',
'buildUpdated': '&buildUpdated',
'isSuperUser': '=isSuperUser'
},
controller: function($scope, $element, $interval, $sanitize, ansi2html, ViewArray,
AngularPollChannel, ApiService, Restangular, UtilService) {
var repoStatusApiCall = ApiService.getRepoBuildStatus;
var repoLogApiCall = ApiService.getRepoBuildLogsAsResource;
if( $scope.isSuperUser ){
repoStatusApiCall = ApiService.getRepoBuildStatusSuperUser;
repoLogApiCall = ApiService.getRepoBuildLogsSuperUserAsResource;
}
$scope.logEntries = null;
$scope.currentParentEntry = null;
$scope.logStartIndex = 0;
$scope.buildLogsText = '';
$scope.currentBuild = null;
$scope.loadError = null;
$scope.pollChannel = null;
var appendToTextLog = function(type, message) {
if (type == 'phase') {
text = 'Starting phase: ' + message + '\n';
} else {
text = message + '\n';
}
$scope.buildLogsText += text.replace(new RegExp("\\033\\[[^m]+m"), '');
};
var processLogs = function(logs, startIndex, endIndex) {
if (!$scope.logEntries) { $scope.logEntries = []; }
// If the start index given is less than that requested, then we've received a larger
// pool of logs, and we need to only consider the new ones.
if (startIndex < $scope.logStartIndex) {
logs = logs.slice($scope.logStartIndex - startIndex);
}
for (var i = 0; i < logs.length; ++i) {
var entry = logs[i];
var type = entry['type'] || 'entry';
if (type == 'command' || type == 'phase' || type == 'error') {
entry['logs'] = ViewArray.create();
entry['index'] = $scope.logStartIndex + i;
$scope.logEntries.push(entry);
$scope.currentParentEntry = entry;
} else if ($scope.currentParentEntry) {
$scope.currentParentEntry['logs'].push(entry);
}
appendToTextLog(type, entry['message']);
}
return endIndex;
};
var handleLogsData = function(logsData, callback) {
// Process the logs we've received.
$scope.logStartIndex = processLogs(logsData['logs'], logsData['start'], logsData['total']);
// If the build status is an error, automatically open the last command run.
var currentBuild = $scope.currentBuild;
if (currentBuild['phase'] == 'error') {
for (var i = $scope.logEntries.length - 1; i >= 0; i--) {
var currentEntry = $scope.logEntries[i];
if (currentEntry['type'] == 'command') {
currentEntry['logs'].setVisible(true);
break;
}
}
}
// If the build phase is an error or a complete, then we mark the channel
// as closed.
callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete');
}
var getBuildStatusAndLogs = function(build, callback) {
var params = {
'repository': build.repository.namespace + '/' + build.repository.name,
'build_uuid': build.id
};
repoStatusApiCall(null, params, true).then(function(resp) {
if (resp.id != $scope.build.id) { callback(false); return; }
// Call the build updated handler.
$scope.buildUpdated({'build': resp});
// Save the current build.
$scope.currentBuild = resp;
// Load the updated logs for the build.
var options = {
'start': $scope.logStartIndex
};
repoLogApiCall(params, true).withOptions(options).get(function(resp) {
// If we get a logs url back, then we need to make another XHR request to retrieve the
// data.
var logsUrl = resp['logs_url'];
if (logsUrl) {
$.ajax({
url: logsUrl,
}).done(function(r) {
$scope.$apply(function() {
handleLogsData(r, callback);
});
}).error(function(xhr) {
$scope.$apply(function() {
if (xhr.status == 0) {
UtilService.isAdBlockEnabled(function(result) {
$scope.loadError = result ? 'blocked': 'request-failed';
});
} else {
$scope.loadError = 'request-failed';
}
});
});
return;
}
handleLogsData(resp, callback);
}, function(resp) {
if (resp.status == 403) {
$scope.loadError = 'unauthorized';
} else {
$scope.loadError = 'request-failed';
}
callback(false);
});
}, function() {
$scope.loadError = 'request-failed';
callback(false);
});
};
var startWatching = function(build) {
// Create a new channel for polling the build status and logs.
var conductStatusAndLogRequest = function(callback) {
getBuildStatusAndLogs(build, callback);
};
// Make sure to cancel any existing watchers first.
stopWatching();
// Register a new poll channel to start watching.
$scope.pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */);
$scope.pollChannel.start();
};
var stopWatching = function() {
if ($scope.pollChannel) {
$scope.pollChannel.stop();
$scope.pollChannel = null;
}
};
$scope.$watch('useTimestamps', function() {
if (!$scope.logEntries) { return; }
$scope.logEntries = $scope.logEntries.slice();
});
$scope.$watch('build', function(build) {
if (build) {
startWatching(build);
} else {
stopWatching();
}
});
$scope.hasLogs = function(container) {
return container.logs.hasEntries;
};
$scope.formatDatetime = function(datetimeString) {
// Note: The standard format required by the Date constructor in JS is
// "2011-10-10T14:48:00" for date-times. The date-time string we get is exactly that,
// but with a space instead of a 'T', so we just replace it.
var dt = new Date(datetimeString.replace(' ', 'T'));
return dt.toLocaleString();
}
$scope.processANSI = function(message, container) {
var filter = container.logs._filter = (container.logs._filter || ansi2html.create());
// Note: order is important here.
var setup = filter.getSetupHtml();
var stream = filter.addInputToStream(message || '');
var teardown = filter.getTeardownHtml();
return setup + stream + teardown;
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,21 @@
/**
* An element which displays a user-friendly message for the current phase of a build.
*/
angular.module('quay').directive('buildMessage', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-message.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'phase': '=phase'
},
controller: function($scope, $element, BuildService) {
$scope.getBuildMessage = function (phase) {
return BuildService.getBuildMessage(phase);
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,23 @@
/**
* An element which displays the status of a build as a mini-bar.
*/
angular.module('quay').directive('buildMiniStatus', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-mini-status.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'build': '=build',
'canView': '=canView'
},
controller: function($scope, $element, BuildService) {
$scope.isBuilding = function(build) {
if (!build) { return true; }
return BuildService.isActive(build)
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,53 @@
/**
* An element which displays a progressbar for the given build.
*/
angular.module('quay').directive('buildProgress', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-progress.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'build': '=build'
},
controller: function($scope, $element) {
$scope.getPercentage = function(buildInfo) {
switch (buildInfo.phase) {
case 'pulling':
return buildInfo.status.pull_completion * 100;
break;
case 'building':
return (buildInfo.status.current_command / buildInfo.status.total_commands) * 100;
break;
case 'pushing':
return buildInfo.status.push_completion * 100;
break;
case 'priming-cache':
return buildInfo.status.cache_completion * 100;
break;
case 'complete':
return 100;
break;
case 'initializing':
case 'checking-cache':
case 'starting':
case 'waiting':
case 'cannot_load':
case 'unpacking':
return 0;
break;
}
return -1;
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,22 @@
/**
* An element which displays an icon representing the state of the build.
*/
angular.module('quay').directive('buildStateIcon', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-state-icon.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'build': '=build'
},
controller: function($scope, $element, BuildService) {
$scope.isBuilding = function(build) {
if (!build) { return true; }
return BuildService.isActive(build);
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,18 @@
/**
* DEPRECATED: An element which displays the status of a build.
*/
angular.module('quay').directive('buildStatus', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/build-status.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'build': '=build'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,7 @@
<span class="channel-icon-element" data-title="{{ $ctrl.name }}" bs-tooltip>
<span class="hexagon" ng-style="{'background-color': $ctrl.color($ctrl.name)}">
<span class="before" ng-style="{'border-bottom-color': $ctrl.color($ctrl.name)}"></span>
<span class="after" ng-style="{'border-top-color': $ctrl.color($ctrl.name)}"></span>
</span>
<b>{{ $ctrl.initial($ctrl.name) }}</b>
</span>

View file

@ -0,0 +1,47 @@
import { Input, Component, Inject } from 'ng-metadata/core';
/**
* A component that displays the icon of a channel.
*/
@Component({
selector: 'channel-icon',
templateUrl: '/static/js/directives/ui/channel-icon/channel-icon.component.html',
})
export class ChannelIconComponent {
@Input('<') public name: string;
private colors: any;
constructor(@Inject('Config') Config: any, @Inject('md5') private md5: any) {
this.colors = Config['CHANNEL_COLORS'];
}
private initial(name: string): string {
if (name == 'alpha') {
return 'α';
}
if (name == 'beta') {
return 'β';
}
if (name == 'stable') {
return 'S';
}
return name[0].toUpperCase();
}
private color(name: string): string {
if (name == 'alpha') {
return this.colors[0];
}
if (name == 'beta') {
return this.colors[1];
}
if (name == 'stable') {
return this.colors[2];
}
var hash: string = this.md5.createHash(name);
var num: number = parseInt(hash.substr(0, 4));
return this.colors[num % this.colors.length];
}
}

View file

@ -0,0 +1,77 @@
import { ClipboardCopyDirective } from './clipboard-copy.directive';
import * as Clipboard from 'clipboard';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("ClipboardCopyDirective", () => {
var directive: ClipboardCopyDirective;
var $elementMock: any;
var $timeoutMock: any;
var $documentMock: any;
var clipboardFactory: any;
var clipboardMock: Mock<Clipboard>;
beforeEach(() => {
$elementMock = new Mock<ng.IAugmentedJQuery>();
$timeoutMock = jasmine.createSpy('$timeoutSpy').and.callFake((fn: () => void, delay) => fn());
$documentMock = new Mock<ng.IDocumentService>();
clipboardMock = new Mock<Clipboard>();
clipboardMock.setup(mock => mock.on).is((eventName: string, callback: (event) => void) => {});
clipboardFactory = jasmine.createSpy('clipboardFactory').and.returnValue(clipboardMock.Object);
directive = new ClipboardCopyDirective(<any>[$elementMock.Object],
$timeoutMock,
<any>[$documentMock.Object],
clipboardFactory);
directive.copyTargetSelector = "#copy-input-box-0";
});
describe("ngAfterContentInit", () => {
it("initializes new Clipboard instance", () => {
const target = new Mock<ng.IAugmentedJQuery>();
$documentMock.setup(mock => mock.querySelector).is(selector => target.Object);
directive.ngAfterContentInit();
expect(clipboardFactory).toHaveBeenCalled();
expect((<Spy>clipboardFactory.calls.argsFor(0)[0])).toEqual($elementMock.Object);
expect((<Spy>clipboardFactory.calls.argsFor(0)[1]['target']())).toEqual(target.Object);
});
it("sets error callback for Clipboard instance", () => {
directive.ngAfterContentInit();
expect((<Spy>clipboardMock.Object.on.calls.argsFor(0)[0])).toEqual('error');
expect((<Spy>clipboardMock.Object.on.calls.argsFor(0)[1])).toBeDefined();
});
it("sets success callback for Clipboard instance", (done) => {
directive.ngAfterContentInit();
expect((<Spy>clipboardMock.Object.on.calls.argsFor(1)[0])).toEqual('success');
expect((<Spy>clipboardMock.Object.on.calls.argsFor(1)[1])).toBeDefined();
done();
});
});
describe("ngOnDestroy", () => {
beforeEach(() => {
clipboardMock.setup(mock => mock.destroy).is(() => null);
});
it("calls method to destroy Clipboard instance if set", (done) => {
directive.ngAfterContentInit();
directive.ngOnDestroy();
expect((<Spy>clipboardMock.Object.destroy)).toHaveBeenCalled();
done();
});
it("does not call method to destroy Clipboard instance if not set", () => {
directive.ngOnDestroy();
expect((<Spy>clipboardMock.Object.destroy)).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,63 @@
import { Directive, Inject, Input, AfterContentInit, OnDestroy } from 'ng-metadata/core';
import * as Clipboard from 'clipboard';
@Directive({
selector: '[clipboardCopy]'
})
export class ClipboardCopyDirective implements AfterContentInit, OnDestroy {
@Input('@clipboardCopy') public copyTargetSelector: string;
private clipboard: Clipboard;
constructor(@Inject('$element') private $element: ng.IAugmentedJQuery,
@Inject('$timeout') private $timeout: ng.ITimeoutService,
@Inject('$document') private $document: ng.IDocumentService,
@Inject('clipboardFactory') private clipboardFactory: (elem, options) => Clipboard) {
}
public ngAfterContentInit(): void {
// FIXME: Need to wait for DOM to render to find target element
this.$timeout(() => {
this.clipboard = this.clipboardFactory(this.$element[0], {target: (trigger) => {
return this.$document[0].querySelector(this.copyTargetSelector);
}});
this.clipboard.on("error", (e) => {
console.error(e);
});
this.clipboard.on('success', (e) => {
const container = e.trigger.parentNode.parentNode.parentNode;
const messageElem = container.querySelector('.clipboard-copied-message');
if (!messageElem) {
return;
}
// Resets the animation.
var elem = messageElem;
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(() => {
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
// Reset the notification.
setTimeout(() => {
elem.style.display = 'none';
}, 5000);
});
}, 100);
}
public ngOnDestroy(): void {
if (this.clipboard) {
this.clipboard.destroy();
}
}
}

View file

@ -0,0 +1,33 @@
<div class="context-path-select-element">
<div class="dropdown-select" placeholder="'Enter a docker context'"
selected-item="$ctrl.selectedContext"
lookahead-items="$ctrl.contexts"
handle-input="$ctrl.setContext(input)"
handle-item-selected="$ctrl.setSelectedContext(datum.value)"
allow-custom-input="true"
hide-dropdown="$ctrl.contexts.length <= 0">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg"
ng-show="$ctrl.isUnknownContext"></i>
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;"
ng-show="!$ctrl.isUnknownContext"></i>
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu pull-right" role="menu">
<li ng-repeat="context in $ctrl.contexts">
<a ng-click="$ctrl.setSelectedContext(context)"
ng-if="context">
<i class="fa fa-folder fa-lg"></i> {{ context }}
</a>
</li>
</ul>
</div>
<div style="padding: 10px">
<div class="co-alert co-alert-danger"
ng-show="!$ctrl.isValidContext && $ctrl.currentContext">
Path is an invalid context.
</div>
</div>
</div>

View file

@ -0,0 +1,107 @@
import { ContextPathSelectComponent, ContextChangeEvent } from './context-path-select.component';
describe("ContextPathSelectComponent", () => {
var component: ContextPathSelectComponent;
var currentContext: string;
var isValidContext: boolean;
var contexts: string[];
beforeEach(() => {
component = new ContextPathSelectComponent();
currentContext = '/';
isValidContext = false;
contexts = ['/'];
component.currentContext = currentContext;
component.isValidContext = isValidContext;
component.contexts = contexts;
});
describe("ngOnChanges", () => {
it("sets valid context flag to true if current context is valid", () => {
component.ngOnChanges({});
expect(component.isValidContext).toBe(true);
});
it("sets valid context flag to false if current context is invalid", () => {
component.currentContext = "asdfdsf";
component.ngOnChanges({});
expect(component.isValidContext).toBe(false);
});
});
describe("setContext", () => {
var newContext: string;
beforeEach(() => {
newContext = '/conf';
});
it("sets current context to given context", () => {
component.setContext(newContext);
expect(component.currentContext).toEqual(newContext);
});
it("sets valid context flag to true if given context is valid", () => {
component.setContext(newContext);
expect(component.isValidContext).toBe(true);
});
it("sets valid context flag to false if given context is invalid", () => {
component.setContext("asdfsadfs");
expect(component.isValidContext).toBe(false);
});
it("emits output event indicating build context changed", (done) => {
component.contextChanged.subscribe((event: ContextChangeEvent) => {
expect(event.contextDir).toEqual(newContext);
expect(event.isValid).toEqual(component.isValidContext);
done();
});
component.setContext(newContext);
});
});
describe("setSelectedContext", () => {
var newContext: string;
beforeEach(() => {
newContext = '/conf';
});
it("sets current context to given context", () => {
component.setSelectedContext(newContext);
expect(component.currentContext).toEqual(newContext);
});
it("sets valid context flag to true if given context is valid", () => {
component.setSelectedContext(newContext);
expect(component.isValidContext).toBe(true);
});
it("sets valid context flag to false if given context is invalid", () => {
component.setSelectedContext("a;lskjdf;ldsa");
expect(component.isValidContext).toBe(false);
});
it("emits output event indicating build context changed", (done) => {
component.contextChanged.subscribe((event: ContextChangeEvent) => {
expect(event.contextDir).toEqual(newContext);
expect(event.isValid).toEqual(component.isValidContext);
done();
});
component.setSelectedContext(newContext);
});
});
});

View file

@ -0,0 +1,59 @@
import { Input, Component, OnChanges, SimpleChanges, Output, EventEmitter } from 'ng-metadata/core';
/**
* A component that allows the user to select the location of the Context in their source code repository.
*/
@Component({
selector: 'context-path-select',
templateUrl: '/static/js/directives/ui/context-path-select/context-path-select.component.html'
})
export class ContextPathSelectComponent implements OnChanges {
@Input('<') public currentContext: string = '';
@Input('<') public contexts: string[];
@Output() public contextChanged: EventEmitter<ContextChangeEvent> = new EventEmitter();
public isValidContext: boolean;
private isUnknownContext: boolean = true;
private selectedContext: string | null = null;
public ngOnChanges(changes: SimpleChanges): void {
this.isValidContext = this.checkContext(this.currentContext, this.contexts);
}
public setContext(context: string): void {
this.currentContext = context;
this.selectedContext = null;
this.isValidContext = this.checkContext(context, this.contexts);
this.contextChanged.emit({contextDir: context, isValid: this.isValidContext});
}
public setSelectedContext(context: string): void {
this.currentContext = context;
this.selectedContext = context;
this.isValidContext = this.checkContext(context, this.contexts);
this.contextChanged.emit({contextDir: context, isValid: this.isValidContext});
}
private checkContext(context: string = '', contexts: string[] = []): boolean {
this.isUnknownContext = false;
var isValidContext: boolean = false;
if (context.length > 0 && context[0] === '/') {
isValidContext = true;
this.isUnknownContext = contexts.indexOf(context) != -1;
}
return isValidContext;
}
}
/**
* Build context changed event.
*/
export type ContextChangeEvent = {
contextDir: string;
isValid: boolean;
};

View file

@ -0,0 +1,74 @@
/**
* Displays a panel for converting the current user to an organization.
*/
angular.module('quay').directive('convertUserToOrg', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/convert-user-to-org.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'info': '=info'
},
controller: function($scope, $element, $location, Features, PlanService, Config, ApiService, CookieService, UserService) {
$scope.convertStep = 0;
$scope.org = {};
$scope.loading = false;
$scope.user = null;
$scope.Features = Features;
$scope.$watch('info', function(info) {
if (info && info.user) {
$scope.user = info.user;
$scope.accountType = 'user';
$scope.convertStep = 0;
$('#convertAccountModal').modal({});
}
});
$scope.showConvertForm = function() {
$scope.convertStep = 1;
};
$scope.nextStep = function() {
if (Features.BILLING) {
PlanService.getMatchingBusinessPlan(function(plan) {
$scope.org.plan = plan;
});
PlanService.getPlans(function(plans) {
$scope.orgPlans = plans;
});
$scope.convertStep = 2;
} else {
$scope.performConversion();
}
};
$scope.performConversion = function() {
if (Config.AUTHENTICATION_TYPE != 'Database') { return; }
$scope.convertStep = 3;
var errorHandler = ApiService.errorDisplay(function() {
$('#convertAccountModal').modal('hide');
});
var data = {
'adminUser': $scope.org.adminUser,
'adminPassword': $scope.org.adminPassword,
'plan': $scope.org.plan ? $scope.org.plan.stripeId : ''
};
ApiService.convertUserToOrganization(data).then(function(resp) {
CookieService.putPermanent('quay.namespace', $scope.user.username);
UserService.load();
$('#convertAccountModal').modal('hide');
$location.path('/');
}, errorHandler);
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,23 @@
/**
* An element which displays a textfield with a "Copy to Clipboard" icon next to it.
*/
angular.module('quay').directive('copyBox', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/copy-box.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'value': '=value',
},
controller: function($scope, $element, $rootScope) {
$scope.disabled = false;
var number = $rootScope.__copyBoxIdCounter || 0;
$rootScope.__copyBoxIdCounter = number + 1;
$scope.inputId = "copy-box-input-" + number;
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,45 @@
import { Input, Component, OnInit, Inject, Host } from 'ng-metadata/core';
import { CorTableComponent } from './cor-table.component';
/**
* Defines a column (optionally sortable) in the table.
*/
@Component({
selector: 'cor-table-col',
template: '',
})
export class CorTableColumn implements OnInit {
@Input('@') public title: string;
@Input('@') public templateurl: string;
@Input('@') public datafield: string;
@Input('@') public sortfield: string;
@Input('@') public selected: string;
@Input('=') public bindModel: any;
@Input('@') public style: string;
@Input('@') public class: string;
@Input('@') public kindof: string;
@Input('<') public itemLimit: number = 5;
constructor(@Host() @Inject(CorTableComponent) private parent: CorTableComponent,
@Inject('TableService') private tableService: any) {
}
public ngOnInit(): void {
this.parent.addColumn(this);
}
public isNumeric(): boolean {
return this.kindof == 'datetime';
}
public processColumnForOrdered(value: any): any {
if (this.kindof == 'datetime' && value) {
return this.tableService.getReversedTimestamp(value);
}
return value;
}
}

View file

@ -0,0 +1,5 @@
.cor-table-element .co-top-bar {
display: flex;
justify-content: flex-end;
align-items: baseline;
}

View file

@ -0,0 +1,67 @@
<div class="cor-table-element">
<span ng-transclude></span>
<!-- Filter -->
<div class="co-top-bar" ng-if="!$ctrl.compact">
<span class="co-filter-box with-options" ng-if="$ctrl.tableData.length && $ctrl.filterFields.length">
<span class="page-controls"
total-count="$ctrl.orderedData.entries.length"
current-page="$ctrl.options.page"
page-size="$ctrl.maxDisplayCount"></span>
<span class="filter-message" ng-if="$ctrl.options.filter">
Showing {{ $ctrl.orderedData.entries.length }} of {{ $ctrl.tableData.length }} {{ ::$ctrl.tableItemTitle }}
</span>
<input class="form-control" type="text"
placeholder="Filter {{ ::$ctrl.tableItemTitle }}..."
ng-model="$ctrl.options.filter"
ng-change="$ctrl.refreshOrder()">
</span>
<!-- Compact/expand rows toggle -->
<div ng-if="!$ctrl.compact && $ctrl.canExpand" class="tab-header-controls">
<div class="btn-group btn-group-sm">
<button class="btn" ng-class="!$ctrl.expandRows ? 'btn-primary active' : 'btn-default'"
ng-click="$ctrl.setExpanded(false)">
Compact
</button>
<button class="btn" ng-class="$ctrl.expandRows ? 'btn-info active' : 'btn-default'"
ng-click="$ctrl.setExpanded(true)">
Expanded
</button>
</div>
</div>
</div>
<!-- Empty -->
<div class="empty" ng-if="!$ctrl.tableData.length && $ctrl.compact != 'true'">
<div class="empty-primary-msg">No {{ ::$ctrl.tableItemTitle }} found.</div>
</div>
<!-- Table -->
<table class="co-table co-fixed-table" ng-show="$ctrl.tableData.length">
<thead>
<td ng-repeat="col in $ctrl.columns"
ng-class="$ctrl.tablePredicateClass(col)" style="{{ ::col.style }}"
class="{{ ::col.class }}">
<a ng-click="$ctrl.setOrder(col)">{{ ::col.title }}</a>
</td>
</thead>
<tbody ng-repeat="item in $ctrl.orderedData.visibleEntries" ng-init="rowIndex = $index"
ng-if="($index >= $ctrl.options.page * $ctrl.maxDisplayCount &&
$index < ($ctrl.options.page + 1) * $ctrl.maxDisplayCount)">
<tr>
<td ng-repeat="col in $ctrl.columns"
style="{{ ::col.style }}" class="{{ ::col.class }}">
<div ng-if="col.templateurl" ng-include="col.templateurl"></div>
<div ng-if="!col.templateurl">{{ item[col.datafield] }}</div>
</td>
</tr>
</tbody>
</table>
<div class="empty" ng-if="!$ctrl.orderedData.entries.length && $ctrl.tableData.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching {{ ::$ctrl.tableItemTitle }} found.</div>
<div class="empty-secondary-msg">Try adjusting your filter above.</div>
</div>
</div>

View file

@ -0,0 +1,126 @@
import { Mock } from 'ts-mocks';
import { CorTableComponent, CorTableOptions } from './cor-table.component';
import { CorTableColumn } from './cor-table-col.component';
import { SimpleChanges } from 'ng-metadata/core';
import { ViewArray } from '../../../services/view-array/view-array';
import Spy = jasmine.Spy;
describe("CorTableComponent", () => {
var component: CorTableComponent;
var tableServiceMock: Mock<any>;
var tableData: any[];
var columnMocks: Mock<CorTableColumn>[];
var orderedDataMock: Mock<ViewArray>;
beforeEach(() => {
orderedDataMock = new Mock<ViewArray>();
orderedDataMock.setup(mock => mock.visibleEntries).is([]);
tableServiceMock = new Mock<any>();
tableServiceMock.setup(mock => mock.buildOrderedItems)
.is((items, options, filterFields, numericFields, extrafilter?) => orderedDataMock.Object);
tableData = [
{name: "apple", last_modified: 1496068383000, version: "1.0.0"},
{name: "pear", last_modified: 1496068383001, version: "1.1.0"},
{name: "orange", last_modified: 1496068383002, version: "1.0.0"},
{name: "banana", last_modified: 1496068383000, version: "2.0.0"},
];
columnMocks = Object.keys(tableData[0])
.map((key, index) => {
const col = new Mock<CorTableColumn>();
col.setup(mock => mock.isNumeric).is(() => index == 1 ? true : false);
col.setup(mock => mock.processColumnForOrdered).is((value) => "dummy");
col.setup(mock => mock.datafield).is(key);
return col;
});
component = new CorTableComponent(tableServiceMock.Object);
component.tableData = tableData;
component.filterFields = ['name', 'version'];
component.compact = false;
component.tableItemTitle = "fruits";
component.maxDisplayCount = 10;
// Add columns
columnMocks.forEach(colMock => component.addColumn(colMock.Object));
(<Spy>tableServiceMock.Object.buildOrderedItems).calls.reset();
});
describe("constructor", () => {
it("sets table options", () => {
expect(component.options.filter).toEqual('');
expect(component.options.reverse).toBe(false);
expect(component.options.predicate).toEqual('');
expect(component.options.page).toEqual(0);
});
});
describe("ngOnChanges", () => {
var changes: SimpleChanges;
it("calls table service to build ordered items if table data is changed", () => {
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
component.ngOnChanges(changes);
expect((<Spy>tableServiceMock.Object.buildOrderedItems)).toHaveBeenCalled();
});
it("passes processed table data to table service", () => {
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
component.tableData = changes['tableData'].currentValue;
component.ngOnChanges(changes);
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[0]).not.toEqual(tableData);
});
it("passes options to table service", () => {
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
component.ngOnChanges(changes);
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[1]).toEqual(component.options);
});
it("passes filter fields to table service", () => {
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
component.ngOnChanges(changes);
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[2]).toEqual(component.filterFields);
});
it("passes numeric fields to table service", () => {
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
component.ngOnChanges(changes);
const expectedNumericCols: string[] = columnMocks.filter(colMock => colMock.Object.isNumeric())
.map(colMock => colMock.Object.datafield);
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[3]).toEqual(expectedNumericCols);
});
it("resets to first page if table data is changed", () => {
component.options.page = 1;
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
component.ngOnChanges(changes);
expect(component.options.page).toEqual(0);
});
});
describe("addColumn", () => {
var columnMock: Mock<CorTableColumn>;
beforeEach(() => {
columnMock = new Mock<CorTableColumn>();
columnMock.setup(mock => mock.isNumeric).is(() => false);
});
it("calls table service to build ordered items with new column", () => {
component.addColumn(columnMock.Object);
expect((<Spy>tableServiceMock.Object.buildOrderedItems)).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,110 @@
import { Input, Component, OnChanges, SimpleChanges, Inject } from 'ng-metadata/core';
import { CorTableColumn } from './cor-table-col.component';
import { ViewArray } from '../../../services/view-array/view-array';
import './cor-table.component.css';
/**
* A component that displays a table of information, with optional filtering and automatic sorting.
*/
@Component({
selector: 'cor-table',
templateUrl: '/static/js/directives/ui/cor-table/cor-table.component.html',
legacy: {
transclude: true
}
})
export class CorTableComponent implements OnChanges {
@Input('<') public tableData: any[] = [];
@Input('@') public tableItemTitle: string;
@Input('<') public filterFields: string[];
@Input('<') public compact: boolean = false;
@Input('<') public maxDisplayCount: number = 10;
@Input('<') public canExpand: boolean = false;
@Input('<') public expandRows: boolean = false;
public orderedData: ViewArray;
public options: CorTableOptions = {
filter: '',
reverse: false,
predicate: '',
page: 0,
};
private rows: CorTableRow[] = [];
private columns: CorTableColumn[] = [];
constructor(@Inject('TableService') private tableService: any) {
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes['tableData'] !== undefined) {
this.refreshOrder();
}
}
public addColumn(col: CorTableColumn): void {
this.columns.push(col);
if (col.selected == 'true') {
this.options['predicate'] = col.datafield;
}
this.refreshOrder();
}
private setOrder(col: CorTableColumn): void {
this.tableService.orderBy(col.datafield, this.options);
this.refreshOrder();
}
private setExpanded(isExpanded: boolean): void {
this.expandRows = isExpanded;
this.rows.forEach((row) => row.expanded = isExpanded);
}
private tablePredicateClass(col: CorTableColumn, options: any) {
return this.tableService.tablePredicateClass(col.datafield, this.options.predicate, this.options.reverse);
}
private refreshOrder(): void {
this.options.page = 0;
var columnMap: {[name: string]: CorTableColumn} = {};
this.columns.forEach(function(col) {
columnMap[col.datafield] = col;
});
const numericCols: string[] = this.columns.filter(col => col.isNumeric())
.map(col => col.datafield);
const processed: any[] = this.tableData.map((item) => {
Object.keys(item).forEach((key) => {
if (columnMap[key]) {
item[key] = columnMap[key].processColumnForOrdered(item[key]);
}
});
return item;
});
this.orderedData = this.tableService.buildOrderedItems(processed, this.options, this.filterFields, numericCols);
this.rows = this.orderedData.visibleEntries.map((item) => Object.assign({}, {expanded: false, rowData: item}));
}
}
export type CorTableOptions = {
filter: string;
reverse: boolean;
predicate: string;
page: number;
};
export type CorTableRow = {
expanded: boolean;
rowData: any;
};

View file

@ -0,0 +1,61 @@
import { CorCookieTabsDirective } from './cor-cookie-tabs.directive';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
import { Mock } from 'ts-mocks';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import Spy = jasmine.Spy;
describe("CorCookieTabsDirective", () => {
var directive: CorCookieTabsDirective;
var panelMock: Mock<CorTabPanelComponent>;
var cookieServiceMock: Mock<any>;
var activeTab: BehaviorSubject<string>;
beforeEach(() => {
activeTab = new BehaviorSubject<string>(null);
spyOn(activeTab, "subscribe").and.returnValue(null);
panelMock = new Mock<CorTabPanelComponent>();
panelMock.setup(mock => mock.activeTab).is(activeTab);
cookieServiceMock = new Mock<any>();
cookieServiceMock.setup(mock => mock.putPermanent).is((cookieName, value) => null);
directive = new CorCookieTabsDirective(panelMock.Object, cookieServiceMock.Object);
directive.cookieName = "quay.credentialsTab";
});
describe("ngAfterContentInit", () => {
const tabId: string = "description";
beforeEach(() => {
cookieServiceMock.setup(mock => mock.get).is((name) => tabId);
spyOn(activeTab, "next").and.returnValue(null);
});
it("calls cookie service to retrieve initial tab id", () => {
directive.ngAfterContentInit();
expect((<Spy>cookieServiceMock.Object.get).calls.argsFor(0)[0]).toEqual(directive.cookieName);
});
it("emits retrieved tab id as next active tab", () => {
directive.ngAfterContentInit();
expect((<Spy>panelMock.Object.activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
});
it("subscribes to active tab changes", () => {
directive.ngAfterContentInit();
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
});
it("calls cookie service to put new permanent cookie on active tab changes", () => {
directive.ngAfterContentInit();
const tabId: string = "description";
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](tabId);
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[0]).toEqual(directive.cookieName);
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[1]).toEqual(tabId);
});
});
});

View file

@ -0,0 +1,30 @@
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
/**
* Adds routing capabilities to cor-tab-panel using a browser cookie.
*/
@Directive({
selector: '[corCookieTabs]'
})
export class CorCookieTabsDirective implements AfterContentInit {
@Input('@corCookieTabs') public cookieName: string;
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
@Inject('CookieService') private cookieService: any) {
}
public ngAfterContentInit(): void {
// Set initial tab
const tabId: string = this.cookieService.get(this.cookieName);
this.panel.activeTab.next(tabId);
this.panel.activeTab.subscribe((tab: string) => {
this.cookieService.putPermanent(this.cookieName, tab);
});
}
}

View file

@ -0,0 +1,73 @@
import { CorNavTabsDirective } from './cor-nav-tabs.directive';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
import { Mock } from 'ts-mocks';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import Spy = jasmine.Spy;
describe("CorNavTabsDirective", () => {
var directive: CorNavTabsDirective;
var panelMock: Mock<CorTabPanelComponent>;
var $locationMock: Mock<ng.ILocationService>;
var $rootScopeMock: Mock<ng.IRootScopeService>;
var activeTab: BehaviorSubject<string>;
const tabId: string = "description";
beforeEach(() => {
activeTab = new BehaviorSubject<string>(null);
spyOn(activeTab, "next").and.returnValue(null);
panelMock = new Mock<CorTabPanelComponent>();
panelMock.setup(mock => mock.activeTab).is(activeTab);
$locationMock = new Mock<ng.ILocationService>();
$locationMock.setup(mock => mock.search).is(() => <any>{tab: tabId});
$rootScopeMock = new Mock<ng.IRootScopeService>();
$rootScopeMock.setup(mock => mock.$on);
directive = new CorNavTabsDirective(panelMock.Object, $locationMock.Object, $rootScopeMock.Object);
});
describe("constructor", () => {
it("subscribes to $routeUpdate event on the root scope", () => {
expect((<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[0]).toEqual("$routeUpdate");
});
it("calls location service to retrieve tab id from URL query parameters on route update", () => {
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
});
it("emits retrieved tab id as next active tab on route update", () => {
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
});
});
describe("ngAfterContentInit", () => {
const path: string = "quay.io/repository/devtable/simple";
beforeEach(() => {
$locationMock.setup(mock => mock.path).is(() => <any>path);
});
it("calls location service to retrieve the current URL path and sets panel's base path", () => {
directive.ngAfterContentInit();
expect(panelMock.Object.basePath).toEqual(path);
});
it("calls location service to retrieve tab id from URL query parameters", () => {
directive.ngAfterContentInit();
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
});
it("emits retrieved tab id as next active tab", () => {
directive.ngAfterContentInit();
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
});
});
});

View file

@ -0,0 +1,29 @@
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
/**
* Adds routing capabilities to cor-tab-panel, either using URL query parameters, or browser cookie.
*/
@Directive({
selector: '[corNavTabs]'
})
export class CorNavTabsDirective implements AfterContentInit {
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
@Inject('$location') private $location: ng.ILocationService,
@Inject('$rootScope') private $rootScope: ng.IRootScopeService) {
this.$rootScope.$on('$routeUpdate', () => {
const tabId: string = this.$location.search()['tab'];
this.panel.activeTab.next(tabId);
});
}
public ngAfterContentInit(): void {
this.panel.basePath = this.$location.path();
// Set initial tab
const tabId: string = this.$location.search()['tab'];
this.panel.activeTab.next(tabId);
}
}

View file

@ -0,0 +1 @@
<div class="co-tab-content tab-content col-md-11" ng-transclude></div>

View file

@ -0,0 +1,17 @@
import { Component } from 'ng-metadata/core';
/**
* A component that is placed under a cor-tabs to wrap tab content with additional styling.
*/
@Component({
selector: 'cor-tab-content',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.html',
legacy: {
transclude: true,
replace: true,
}
})
export class CorTabContentComponent {
}

View file

@ -0,0 +1,3 @@
<div class="co-tab-pane" ng-show="$ctrl.isActiveTab">
<div ng-transclude />
</div>

View file

@ -0,0 +1,63 @@
import { CorTabPaneComponent } from './cor-tab-pane.component';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
import { Mock } from 'ts-mocks';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import Spy = jasmine.Spy;
describe("CorTabPaneComponent", () => {
var component: CorTabPaneComponent;
var panelMock: Mock<CorTabPanelComponent>;
var activeTab: BehaviorSubject<string>;
beforeEach(() => {
activeTab = new BehaviorSubject<string>(null);
spyOn(activeTab, "subscribe").and.callThrough();
panelMock = new Mock<CorTabPanelComponent>();
panelMock.setup(mock => mock.activeTab).is(activeTab);
component = new CorTabPaneComponent(panelMock.Object);
component.id = 'description';
});
describe("ngOnInit", () => {
beforeEach(() => {
panelMock.setup(mock => mock.addTabPane);
});
it("adds self as tab pane to panel", () => {
component.ngOnInit();
expect((<Spy>panelMock.Object.addTabPane).calls.argsFor(0)[0]).toBe(component);
});
it("subscribes to active tab changes", () => {
component.ngOnInit();
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
});
it("does nothing if active tab ID is undefined", () => {
component.ngOnInit();
component.isActiveTab = true;
panelMock.Object.activeTab.next(null);
expect(component.isActiveTab).toEqual(true);
});
it("sets self as active if active tab ID matches tab ID", () => {
component.ngOnInit();
panelMock.Object.activeTab.next(component.id);
expect(component.isActiveTab).toEqual(true);
});
it("sets self as inactive if active tab ID does not match tab ID", () => {
component.ngOnInit();
panelMock.Object.activeTab.next(component.id.split('').reverse().join(''));
expect(component.isActiveTab).toEqual(false);
});
});
});

View file

@ -0,0 +1,35 @@
import { Component, Input, Inject, Host, OnInit } from 'ng-metadata/core';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
import 'rxjs/add/operator/filter';
/**
* A component that creates a single tab pane under a cor-tabs component.
*/
@Component({
selector: 'cor-tab-pane',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.html',
legacy: {
transclude: true,
}
})
export class CorTabPaneComponent implements OnInit {
@Input('@') public id: string;
public isActiveTab: boolean = false;
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
}
public ngOnInit(): void {
this.panel.addTabPane(this);
this.panel.activeTab
.filter(tabId => tabId != undefined)
.subscribe((tabId: string) => {
this.isActiveTab = (this.id === tabId);
});
}
}

View file

@ -0,0 +1,3 @@
<div class="co-main-content-panel co-tab-panel co-fx-box-shadow-heavy">
<div class="co-tab-container" ng-class="$ctrl.isVertical() ? 'vertical': 'horizontal'" ng-transclude></div>
</div>

View file

@ -0,0 +1,132 @@
import { CorTabPanelComponent } from './cor-tab-panel.component';
import { CorTabComponent } from '../cor-tab/cor-tab.component';
import { SimpleChanges } from 'ng-metadata/core';
import Spy = jasmine.Spy;
describe("CorTabPanelComponent", () => {
var component: CorTabPanelComponent;
beforeEach(() => {
component = new CorTabPanelComponent();
});
describe("ngOnInit", () => {
var tabs: CorTabComponent[] = [];
beforeEach(() => {
// Add tabs to panel
tabs.push(new CorTabComponent(component));
tabs[0].tabId = "info";
tabs.forEach((tab) => component.addTab(tab));
spyOn(component.activeTab, "subscribe").and.callThrough();
spyOn(component.activeTab, "next").and.callThrough();
spyOn(component.tabChange, "emit").and.returnValue(null);
});
it("subscribes to active tab changes", () => {
component.ngOnInit();
expect(<Spy>component.activeTab.subscribe).toHaveBeenCalled();
});
it("emits next active tab with tab ID of first registered tab if given tab ID is null", () => {
component.ngOnInit();
component.activeTab.next(null);
expect((<Spy>component.activeTab.next).calls.argsFor(1)[0]).toEqual(tabs[0].tabId);
});
it("does not emit output event for tab change if tab ID is null", () => {
component.ngOnInit();
component.activeTab.next(null);
expect((<Spy>component.tabChange.emit).calls.allArgs).not.toContain(null);
});
it("emits output event for tab change when tab ID is not null", () => {
component.ngOnInit();
const tabId: string = "description";
component.activeTab.next(tabId);
expect((<Spy>component.tabChange.emit).calls.argsFor(1)[0]).toEqual(tabId);
});
});
describe("ngOnChanges", () => {
var changes: SimpleChanges;
var tabs: CorTabComponent[] = [];
beforeEach(() => {
// Add tabs to panel
tabs.push(new CorTabComponent(component));
tabs.forEach((tab) => component.addTab(tab));
changes = {
'selectedIndex': {
currentValue: 0,
previousValue: null,
isFirstChange: () => false
},
};
spyOn(component.activeTab, "next").and.returnValue(null);
});
it("emits next active tab if 'selectedIndex' input changes and is valid", () => {
component.ngOnChanges(changes);
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tabs[changes['selectedIndex'].currentValue].tabId);
});
it("does nothing if 'selectedIndex' input changed to invalid value", () => {
changes['selectedIndex'].currentValue = 100;
component.ngOnChanges(changes);
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
});
});
describe("addTab", () => {
beforeEach(() => {
spyOn(component.activeTab, "next").and.returnValue(null);
});
it("emits next active tab if it is not set", () => {
const tab: CorTabComponent = new CorTabComponent(component);
component.addTab(tab);
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tab.tabId);
});
it("does not emit next active tab if it is already set", () => {
spyOn(component.activeTab, "getValue").and.returnValue("description");
const tab: CorTabComponent = new CorTabComponent(component);
component.addTab(tab);
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
});
});
describe("addTabPane", () => {
});
describe("isVertical", () => {
it("returns true if orientation is 'vertical'", () => {
component.orientation = 'vertical';
const isVertical: boolean = component.isVertical();
expect(isVertical).toBe(true);
});
it("returns false if orientation is not 'vertical'", () => {
const isVertical: boolean = component.isVertical();
expect(isVertical).toBe(false);
});
});
});

View file

@ -0,0 +1,65 @@
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, OnInit } from 'ng-metadata/core';
import { CorTabComponent } from '../cor-tab/cor-tab.component';
import { CorTabPaneComponent } from '../cor-tab-pane/cor-tab-pane.component';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
/**
* A component that contains a cor-tabs and handles all of its logic.
*/
@Component({
selector: 'cor-tab-panel',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.html',
legacy: {
transclude: true
}
})
export class CorTabPanelComponent implements OnInit, OnChanges {
@Input('@') public orientation: 'horizontal' | 'vertical' = 'horizontal';
@Output() public tabChange: EventEmitter<string> = new EventEmitter();
public basePath: string;
public activeTab = new BehaviorSubject<string>(null);
private tabs: CorTabComponent[] = [];
private tabPanes: {[id: string]: CorTabPaneComponent} = {};
public ngOnInit(): void {
this.activeTab.subscribe((tabId: string) => {
// Catch null values and replace with tabId of first tab
if (!tabId && this.tabs[0]) {
this.activeTab.next(this.tabs[0].tabId);
} else {
this.tabChange.emit(tabId);
}
});
}
public ngOnChanges(changes: SimpleChanges): void {
switch (Object.keys(changes)[0]) {
case 'selectedIndex':
if (this.tabs.length > changes['selectedIndex'].currentValue) {
this.activeTab.next(this.tabs[changes['selectedIndex'].currentValue].tabId);
}
break;
}
}
public addTab(tab: CorTabComponent): void {
this.tabs.push(tab);
if (!this.activeTab.getValue()) {
this.activeTab.next(this.tabs[0].tabId);
}
}
public addTabPane(tabPane: CorTabPaneComponent): void {
this.tabPanes[tabPane.id] = tabPane;
}
public isVertical(): boolean {
return this.orientation == 'vertical';
}
}

View file

@ -0,0 +1,13 @@
<li class="cor-tab-itself" ng-class="{'active': $ctrl.isActive, 'co-top-tab': !$ctrl.parent.isVertical()}">
<a href="{{ $ctrl.panel.basePath ? $ctrl.panel.basePath + '?tab=' + $ctrl.tabId : '' }}"
ng-click="$ctrl.tabClicked($event)">
<span class="cor-tab-icon"
data-title="{{ ::($ctrl.panel.isVertical() ? $ctrl.tabTitle : '') }}"
data-placement="right"
data-container="body"
style="display: inline-block"
bs-tooltip>
<span ng-transclude /><span class="horizontal-label">{{ ::$ctrl.tabTitle }}</span>
</span>
</a>
</li>

View file

@ -0,0 +1,85 @@
import { CorTabComponent } from './cor-tab.component';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
import { Mock } from 'ts-mocks';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import Spy = jasmine.Spy;
describe("CorTabComponent", () => {
var component: CorTabComponent;
var panelMock: Mock<CorTabPanelComponent>;
var activeTab: BehaviorSubject<string>;
beforeEach(() => {
activeTab = new BehaviorSubject<string>(null);
spyOn(activeTab, "subscribe").and.callThrough();
panelMock = new Mock<CorTabPanelComponent>();
panelMock.setup(mock => mock.activeTab).is(activeTab);
component = new CorTabComponent(panelMock.Object);
});
describe("ngOnInit", () => {
beforeEach(() => {
panelMock.setup(mock => mock.addTab);
spyOn(component.tabInit, "emit").and.returnValue(null);
spyOn(component.tabShow, "emit").and.returnValue(null);
spyOn(component.tabHide, "emit").and.returnValue(null);
component.tabId = "description";
});
it("subscribes to active tab changes", () => {
component.ngOnInit();
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
});
it("does nothing if active tab ID is undefined", () => {
component.ngOnInit();
panelMock.Object.activeTab.next(null);
expect(<Spy>component.tabInit.emit).not.toHaveBeenCalled();
expect(<Spy>component.tabShow.emit).not.toHaveBeenCalled();
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
});
it("emits output event for tab init if it is new active tab", () => {
component.ngOnInit();
panelMock.Object.activeTab.next(component.tabId);
expect(<Spy>component.tabInit.emit).toHaveBeenCalled();
});
it("emits output event for tab show if it is new active tab", () => {
component.ngOnInit();
panelMock.Object.activeTab.next(component.tabId);
expect(<Spy>component.tabShow.emit).toHaveBeenCalled();
});
it("emits output event for tab hide if active tab changes to different tab", () => {
const newTabId: string = component.tabId.split('').reverse().join('');
component.ngOnInit();
// Call twice, first time to set 'isActive' to true
panelMock.Object.activeTab.next(component.tabId);
panelMock.Object.activeTab.next(newTabId);
expect(<Spy>component.tabHide.emit).toHaveBeenCalled();
});
it("does not emit output event for tab hide if was not previously active tab", () => {
const newTabId: string = component.tabId.split('').reverse().join('');
component.ngOnInit();
panelMock.Object.activeTab.next(newTabId);
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
});
it("adds self as tab to panel", () => {
component.ngOnInit();
expect((<Spy>panelMock.Object.addTab).calls.argsFor(0)[0]).toBe(component);
});
});
});

View file

@ -0,0 +1,56 @@
import { Component, Input, Output, Inject, EventEmitter, Host, OnInit } from 'ng-metadata/core';
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
import 'rxjs/add/operator/filter';
/**
* A component that creates a single tab under a cor-tabs component.
*/
@Component({
selector: 'cor-tab',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html',
legacy: {
transclude: true,
}
})
export class CorTabComponent implements OnInit {
@Input('@') public tabId: string;
@Input('@') public tabTitle: string;
@Input('<') public tabActive: boolean = false;
@Output() public tabInit: EventEmitter<any> = new EventEmitter();
@Output() public tabShow: EventEmitter<any> = new EventEmitter();
@Output() public tabHide: EventEmitter<any> = new EventEmitter();
private isActive: boolean = false;
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
}
public ngOnInit(): void {
this.isActive = this.tabActive;
this.panel.activeTab
.filter(tabId => tabId != undefined)
.subscribe((tabId: string) => {
if (!this.isActive && this.tabId === tabId) {
this.isActive = true;
this.tabInit.emit({});
this.tabShow.emit({});
} else if (this.isActive && this.tabId !== tabId) {
this.isActive = false;
this.tabHide.emit({});
}
});
this.panel.addTab(this);
}
private tabClicked(event: MouseEvent): void {
if (!this.panel.basePath) {
event.preventDefault();
this.panel.activeTab.next(this.tabId);
}
}
}

View file

@ -0,0 +1,4 @@
<span class="co-tab-element" ng-class="$ctrl.isClosed ? 'closed' : 'open'">
<span class="xs-toggle" ng-click="$ctrl.toggleClosed($event)"></span>
<ul ng-class="$ctrl.parent.isVertical() ? 'co-tabs col-md-1' : 'co-top-tab-bar'" ng-transclude></ul>
</span>

View file

@ -0,0 +1,26 @@
import { Component, Input, Output, Inject, EventEmitter, Host } from 'ng-metadata/core';
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
/**
* A component that holds the actual tabs.
*/
@Component({
selector: 'cor-tabs',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tabs.component.html',
legacy: {
transclude: true,
}
})
export class CorTabsComponent {
private isClosed: boolean = true;
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
}
private toggleClosed(e): void {
this.isClosed = !this.isClosed;
}
}

View file

@ -0,0 +1,33 @@
import { NgModule } from 'ng-metadata/core';
import { CorTabsComponent } from './cor-tabs.component';
import { CorTabComponent } from './cor-tab/cor-tab.component';
import { CorNavTabsDirective } from './cor-nav-tabs/cor-nav-tabs.directive';
import { CorTabContentComponent } from './cor-tab-content/cor-tab-content.component';
import { CorTabPaneComponent } from './cor-tab-pane/cor-tab-pane.component';
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
import { CorCookieTabsDirective } from './cor-cookie-tabs/cor-cookie-tabs.directive';
/**
* Module containing everything needed for cor-tabs.
*/
@NgModule({
imports: [
],
declarations: [
CorNavTabsDirective,
CorTabComponent,
CorTabContentComponent,
CorTabPaneComponent,
CorTabPanelComponent,
CorTabsComponent,
CorCookieTabsDirective,
],
providers: [
]
})
export class CorTabsModule {
}

View file

@ -0,0 +1,13 @@
import { element, by, browser, $, ElementFinder, ExpectedConditions as until } from 'protractor';
export class CorTabsViewObject {
public selectTabByTitle(title: string) {
return $(`cor-tab[tab-title="${title}"] a`).click();
}
public isActiveTab(title: string) {
return $(`cor-tab[tab-title="${title}"] .cor-tab-itself.active`).isPresent();
}
}

View file

@ -0,0 +1,119 @@
/**
* An element which displays a create entity dialog.
*/
angular.module('quay').directive('createEntityDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/create-entity-dialog.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'info': '=info',
'entityKind': '@entityKind',
'entityTitle': '@entityTitle',
'entityIcon': '@entityIcon',
'entityNameRegex': '@entityNameRegex',
'allowEntityDescription': '@allowEntityDescription',
'entityCreateRequested': '&entityCreateRequested',
'entityCreateCompleted': '&entityCreateCompleted'
},
controller: function($scope, $element, ApiService, UIService, UserService) {
$scope.context = {
'setPermissionsCounter': 0
};
$scope.$on('$destroy', function() {
if ($scope.inBody) {
document.body.removeChild($element[0]);
}
});
$scope.hide = function() {
$element.find('.modal').modal('hide');
if ($scope.entity) {
$scope.entityCreateCompleted({'entity': $scope.entity});
$scope.entity = null;
}
};
$scope.show = function() {
$scope.entityName = null;
$scope.entityDescription = null;
$scope.entity = null;
$scope.entityForPermissions = null;
$scope.creating = false;
$scope.view = 'enterName';
$scope.enterNameForm.$setPristine(true);
// Move the dialog to the body to prevent it from nesting if called
// from within another dialog.
$element.find('.modal').modal({});
$scope.inBody = true;
document.body.appendChild($element[0]);
};
var entityCreateCallback = function(entity) {
$scope.entity = entity;
if (!entity || $scope.info.skip_permissions) {
$scope.hide();
return;
}
};
$scope.createEntity = function() {
$scope.view = 'creating';
$scope.entityCreateRequested({
'name': $scope.entityName,
'description': $scope.entityDescription,
'callback': entityCreateCallback
});
};
$scope.permissionsSet = function(repositories) {
$scope.entity['repo_count'] = repositories.length;
$scope.hide();
};
$scope.settingPermissions = function() {
$scope.view = 'settingperms';
};
$scope.setPermissions = function() {
$scope.context.setPermissionsCounter++;
};
$scope.repositoriesLoaded = function(repositories) {
if (repositories && !repositories.length) {
$scope.hide();
return;
}
$scope.view = 'setperms';
};
$scope.$watch('entityNameRegex', function(r) {
if (r) {
$scope.entityNameRegexObj = new RegExp(r);
}
});
$scope.$watch('info', function(info) {
if (!info || !info.namespace) {
$scope.hide();
return;
}
$scope.namespace = UserService.getNamespace(info.namespace);
if ($scope.namespace) {
$scope.show();
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,170 @@
/**
* An element which displays a form to register a new external notification on a repository.
*/
angular.module('quay').directive('createExternalNotification', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/create-external-notification.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'notificationCreated': '&notificationCreated',
'defaultData': '=defaultData'
},
controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) {
$scope.currentEvent = null;
$scope.currentMethod = null;
$scope.status = '';
$scope.currentConfig = {};
$scope.currentEventConfig = {};
$scope.clearCounter = 0;
$scope.unauthorizedEmail = false;
$scope.events = ExternalNotificationData.getSupportedEvents();
$scope.methods = ExternalNotificationData.getSupportedMethods();
$scope.getPattern = function(field) {
if (field._cached_regex) {
return field._cached_regex;
}
field._cached_regex = new RegExp(field.pattern);
return field._cached_regex;
};
$scope.setEvent = function(event) {
$scope.currentEvent = event;
$scope.currentEventConfig = {};
};
$scope.setMethod = function(method) {
$scope.currentConfig = {};
$scope.currentMethod = method;
$scope.unauthorizedEmail = false;
};
$scope.hasRegexMismatch = function(err, fieldName) {
if (!err.pattern) {
return;
}
for (var i = 0; i < err.pattern.length; ++i) {
var current = err.pattern[i];
var value = current.$viewValue;
var elem = $element.find('#' + fieldName);
if (value == elem[0].value) {
return true;
}
}
return false;
};
$scope.createNotification = function() {
if (!$scope.currentConfig.email) {
$scope.performCreateNotification();
return;
}
$scope.status = 'checking-email';
$scope.checkEmailAuthorization();
};
$scope.checkEmailAuthorization = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'email': $scope.currentConfig.email
};
ApiService.checkRepoEmailAuthorized(null, params).then(function(resp) {
$scope.handleEmailCheck(resp.confirmed);
}, function(resp) {
$scope.handleEmailCheck(false);
});
};
$scope.performCreateNotification = function() {
$scope.status = 'creating';
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name
};
var data = {
'event': $scope.currentEvent.id,
'method': $scope.currentMethod.id,
'config': $scope.currentConfig,
'eventConfig': $scope.currentEventConfig,
'title': $scope.currentTitle
};
ApiService.createRepoNotification(data, params).then(function(resp) {
$scope.status = '';
$scope.notificationCreated({'notification': resp});
});
};
$scope.handleEmailCheck = function(isAuthorized) {
if (isAuthorized) {
$scope.performCreateNotification();
return;
}
if ($scope.status == 'authorizing-email-sent') {
$scope.watchEmail();
} else {
$scope.status = 'unauthorized-email';
}
$scope.unauthorizedEmail = true;
$('#authorizeEmailModal').modal({});
};
$scope.sendAuthEmail = function() {
$scope.status = 'authorizing-email';
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'email': $scope.currentConfig.email
};
ApiService.sendAuthorizeRepoEmail(null, params).then(function(resp) {
$scope.status = 'authorizing-email-sent';
$scope.watchEmail();
});
};
$scope.watchEmail = function() {
// TODO: change this to SSE?
$timeout(function() {
$scope.checkEmailAuthorization();
}, 1000);
};
$scope.cancelEmailAuth = function() {
$scope.status = '';
$('#authorizeEmailModal').modal('hide');
};
$scope.getHelpUrl = function(field, config) {
var helpUrl = field['help_url'];
if (!helpUrl) {
return null;
}
return StringBuilderService.buildUrl(helpUrl, config);
};
$scope.$watch('defaultData', function(counter) {
if ($scope.defaultData && $scope.defaultData['currentEvent']) {
$scope.setEvent($scope.defaultData['currentEvent']);
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,47 @@
/**
* An element which displays a dialog for creating a robot account.
*/
angular.module('quay').directive('createRobotDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/create-robot-dialog.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'info': '=info',
'robotCreated': '&robotCreated'
},
controller: function($scope, $element, ApiService, UserService, NAME_PATTERNS) {
$scope.ROBOT_PATTERN = NAME_PATTERNS.ROBOT_PATTERN;
$scope.robotFinished = function(robot) {
$scope.robotCreated({'robot': robot});
};
$scope.createRobot = function(name, description, callback) {
var organization = $scope.info.namespace;
if (!UserService.isOrganization(organization)) {
organization = null;
}
var params = {
'robot_shortname': name
};
var data = {
'description': description || ''
};
var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() {
callback(null);
});
ApiService.createRobot(organization, data, params).then(function(resp) {
callback(resp);
}, errorDisplay);
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,49 @@
/**
* An element which displays a dialog for creating a team.
*/
angular.module('quay').directive('createTeamDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/create-team-dialog.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'info': '=info',
'teamCreated': '&teamCreated'
},
controller: function($scope, $element, ApiService, UserService, NAME_PATTERNS) {
$scope.TEAM_PATTERN = NAME_PATTERNS.TEAM_PATTERN;
$scope.teamFinished = function(team) {
$scope.teamCreated({'team': team});
};
$scope.createTeam = function(name, callback) {
var data = {
'name': name,
'role': 'member'
};
var params = {
'orgname': $scope.info.namespace,
'teamname': name
};
var errorDisplay = ApiService.errorDisplay('Cannot create team', function() {
callback(null);
});
ApiService.updateOrganizationTeam(data, params).then(function(resp) {
if (!resp.new_team) {
callback(null);
bootbox.alert('Team with name "' + resp.name + '" already exists')
return;
}
callback(resp);
}, errorDisplay);
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,218 @@
/**
* An element which displays a credentials dialog.
*/
angular.module('quay').directive('credentialsDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/credentials-dialog.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'credentials': '=credentials',
'secretTitle': '@secretTitle',
'entityTitle': '@entityTitle',
'entityIcon': '@entityIcon'
},
controller: function($scope, $element, $rootScope, Config) {
$scope.Config = Config;
$scope.k8s = {};
$scope.rkt = {};
$scope.docker = {};
$scope.$on('$destroy', function() {
if ($scope.inBody) {
document.body.removeChild($element[0]);
}
});
// Generate a unique ID for the dialog.
if (!$rootScope.credentialsDialogCounter) {
$rootScope.credentialsDialogCounter = 0;
}
$rootScope.credentialsDialogCounter++;
$scope.hide = function() {
$element.find('.modal').modal('hide');
};
$scope.show = function() {
$element.find('.modal').modal({});
// Move the dialog to the body to prevent it from being affected
// by being placed inside other tables.
$scope.inBody = true;
document.body.appendChild($element[0]);
};
$scope.$watch('credentials', function(credentials) {
if (!credentials) {
$scope.hide();
return;
}
$scope.show();
});
$scope.downloadFile = function(info) {
var blob = new Blob([info.contents]);
FileSaver.saveAs(blob, info.filename);
};
$scope.viewFile = function(context) {
context.viewingFile = true;
};
$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.getNamespace = function(credentials) {
if (!credentials || !credentials.username) {
return '';
}
if (credentials.namespace) {
return credentials.namespace;
}
return credentials.username.split('+')[0];
};
$scope.getMesosFilename = function(credentials) {
return $scope.getSuffixedFilename(credentials, 'auth.tar.gz');
};
$scope.getMesosFile = function(credentials) {
var tarFile = new Tar();
tarFile.append('.docker/config.json', $scope.getDockerConfig(credentials), {});
contents = (new Zlib.Gzip(tarFile.getData())).compress();
return {
'filename': $scope.getMesosFilename(credentials),
'contents': contents
}
};
$scope.getDockerConfig = function(credentials) {
var auths = {};
auths[Config['SERVER_HOSTNAME']] = {
'auth': $.base64.encode(credentials.username + ":" + credentials.password),
'email': ''
};
var config = {
'auths': auths
};
return JSON.stringify(config, null, ' ');
};
$scope.getDockerFile = function(credentials) {
return {
'filename': $scope.getRktFilename(credentials),
'contents': $scope.getDockerConfig(credentials)
}
};
$scope.getDockerLogin = function(credentials) {
if (!credentials || !credentials.username) {
return '';
}
var escape = function(v) {
if (!v) { return v; }
return v.replace('$', '\\$');
};
return 'docker login -u="' + escape(credentials.username) + '" -p="' + credentials.password + '" ' + Config['SERVER_HOSTNAME'];
};
$scope.getDockerFilename = function(credentials) {
return $scope.getSuffixedFilename(credentials, 'auth.json')
};
$scope.getRktFile = function(credentials) {
var config = {
'rktKind': 'auth',
'rktVersion': 'v1',
'domains': [Config['SERVER_HOSTNAME']],
'type': 'basic',
'credentials': {
'user': credentials['username'],
'password': credentials['password']
}
};
var contents = JSON.stringify(config, null, ' ');
return {
'filename': $scope.getRktFilename(credentials),
'contents': contents
}
};
$scope.getRktFilename = function(credentials) {
return $scope.getSuffixedFilename(credentials, 'auth.json')
};
$scope.getKubernetesSecretName = function(credentials) {
if (!credentials || !credentials.username) {
return '';
}
return $scope.getSuffixedFilename(credentials, 'pull-secret');
};
$scope.getKubernetesFile = function(credentials) {
var dockerConfigJson = $scope.getDockerConfig(credentials);
var contents = 'apiVersion: v1\n' +
'kind: Secret\n' +
'metadata:\n' +
' name: ' + $scope.getKubernetesSecretName(credentials) + '\n' +
'data:\n' +
' .dockerconfigjson: ' + $.base64.encode(dockerConfigJson) + '\n' +
'type: kubernetes.io/dockerconfigjson'
return {
'filename': $scope.getKubernetesFilename(credentials),
'contents': contents
}
};
$scope.getKubernetesFilename = function(credentials) {
return $scope.getSuffixedFilename(credentials, 'secret.yml')
};
$scope.getEscaped = function(item) {
var escaped = item.replace(/[^a-zA-Z0-9]/g, '-');
if (escaped[0] == '-') {
escaped = escaped.substr(1);
}
return escaped;
};
$scope.getSuffixedFilename = function(credentials, suffix) {
if (!credentials || !credentials.username) {
return '';
}
var prefix = $scope.getEscaped(credentials.username);
if (credentials.title) {
prefix = $scope.getEscaped(credentials.title);
}
return prefix + '-' + suffix;
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,18 @@
/**
* An element which displays a credentials for a build trigger.
*/
angular.module('quay').directive('credentials', function() {
var directiveDefinitionObject = {
templateUrl: '/static/directives/credentials.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'trigger': '=trigger'
},
controller: function($scope, TriggerService) {
TriggerService.populateTemplate($scope, 'credentials');
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,59 @@
/**
* An element which displays a datetime picker.
*/
angular.module('quay').directive('datetimePicker', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/datetime-picker.html',
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,35 @@
/**
* An element which displays a settings table row for deleting a namespace (user or organization).
*/
angular.module('quay').directive('deleteNamespaceView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/delete-namespace-view.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'user': '=user',
'organization': '=organization',
'subscriptionStatus': '=subscriptionStatus',
'namespaceTitle': '@namespaceTitle'
},
controller: function($scope, $element, UserService) {
$scope.context = {};
$scope.showDeleteNamespace = function() {
$scope.deleteNamespaceInfo = {
'user': $scope.user,
'organization': $scope.organization,
'namespace': $scope.user ? $scope.user.username : $scope.organization.name,
'verification': ''
};
};
$scope.deleteNamespace = function(info, callback) {
UserService.deleteNamespace(info, callback);
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,26 @@
/**
* A two-step delete button that slides into view when clicked.
*/
angular.module('quay').directive('deleteUi', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/delete-ui.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'deleteTitle': '=deleteTitle',
'buttonTitle': '=buttonTitle',
'performDelete': '&performDelete'
},
controller: function($scope, $element) {
$scope.buttonTitleInternal = $scope.buttonTitle || 'Delete';
$element.children().attr('tabindex', 0);
$scope.focus = function() {
$element[0].firstChild.focus();
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,79 @@
/**
* An element which displays a dialog for manually starting a dockerfile build.
*/
angular.module('quay').directive('dockerfileBuildDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/dockerfile-build-dialog.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'showNow': '=showNow',
'buildStarted': '&buildStarted'
},
controller: function($scope, $element, ApiService) {
$scope.viewTriggers = false;
$scope.triggers = null;
$scope.viewCounter = 0;
$scope.startTriggerCounter = 0;
$scope.startTrigger = null;
$scope.showTriggers = function(value) {
$scope.viewTriggers = value;
};
$scope.runTriggerNow = function(trigger) {
$element.find('.dockerfilebuildModal').modal('hide');
$scope.startTrigger = trigger;
$scope.startTriggerCounter++;
};
$scope.startBuild = function() {
$scope.buildStarting = true;
$scope.startBuildCallback(function(status, messageOrBuild) {
$element.find('.dockerfilebuildModal').modal('hide');
if (status) {
$scope.buildStarted({'build': messageOrBuild});
} else {
bootbox.alert(messageOrBuild || 'Could not start build');
}
});
};
$scope.readyForBuild = function(startBuild) {
$scope.startBuildCallback = startBuild;
};
$scope.$watch('showNow', function(sn) {
if (sn && $scope.repository) {
$scope.viewTriggers = false;
$scope.startTrigger = null;
$scope.buildStarting = false;
$scope.viewCounter++;
$element.find('.dockerfilebuildModal').modal({});
// Load the triggers (if necessary).
if (!$scope.repository || !$scope.repository.can_admin) {
$scope.triggersResource = null;
$scope.triggers = null;
return;
}
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name
};
$scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) {
$scope.triggers = resp.triggers;
$scope.viewTriggers = $scope.triggers.length > 0;
});
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,143 @@
/**
* An element which displays a form for manually starting a dockerfile build.
*/
angular.module('quay').directive('dockerfileBuildForm', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/dockerfile-build-form.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'isReady': '=?isReady',
'reset': '=?reset',
'readyForBuild': '&readyForBuild'
},
controller: function($scope, $element, ApiService, DockerfileService, Config) {
$scope.state = 'empty';
var checkPrivateImage = function(baseImage) {
var params = {
'repository': baseImage
};
$scope.state = 'checking-image';
ApiService.getRepo(null, params).then(function(repository) {
$scope.privateBaseRepository = repository.is_public ? null : baseImage;
$scope.state = repository.is_public ? 'ready' : 'awaiting-bot';
}, function() {
$scope.privateBaseRepository = null;
$scope.state = 'ready';
});
};
$scope.handleFilesSelected = function(files, opt_callback) {
$scope.pullEntity = null;
$scope.state = 'checking';
$scope.selectedFiles = files;
DockerfileService.getDockerfile(files[0])
.then(function(dockerfileInfo) {
var baseImage = dockerfileInfo.getRegistryBaseImage();
if (baseImage) {
checkPrivateImage(baseImage);
} else {
$scope.state = 'ready';
}
$scope.$apply(function() {
opt_callback && opt_callback(true, 'Dockerfile found and valid')
});
})
.catch(function(error) {
$scope.state = 'empty';
$scope.privateBaseRepository = null;
$scope.$apply(function() {
opt_callback && opt_callback(false, error || 'Could not find valid Dockerfile');
});
});
};
$scope.handleFilesCleared = function() {
$scope.state = 'empty';
$scope.pullEntity = null;
$scope.privateBaseRepository = null;
};
$scope.handleFilesValidated = function(uploadFiles) {
$scope.uploadFilesCallback = uploadFiles;
};
var requestRepoBuild = function(buildPackId, opt_callback) {
var repo = $scope.repository;
var data = {
'file_id': buildPackId
};
if ($scope.pullEntity) {
data['pull_robot'] = $scope.pullEntity['name'];
}
var params = {
'repository': repo.namespace + '/' + repo.name,
};
ApiService.requestRepoBuild(data, params).then(function(resp) {
opt_callback && opt_callback(true, resp);
}, function(resp) {
opt_callback && opt_callback(false, 'Could not start build');
$scope.handleFilesSelected($scope.selectedFiles);
});
};
var startBuild = function(opt_callback) {
$scope.state = 'uploading-files';
$scope.uploadFilesCallback(function(status, messageOrIds) {
$scope.state = 'starting-build';
requestRepoBuild(messageOrIds[0], opt_callback);
});
};
var checkEntity = function() {
if (!$scope.pullEntity) {
$scope.state = 'awaiting-bot';
return;
}
$scope.state = 'checking-bot';
$scope.currentRobotHasPermission = null;
var permParams = {
'repository': $scope.privateBaseRepository,
'username': $scope.pullEntity.name
};
ApiService.getUserTransitivePermission(null, permParams).then(function(resp) {
$scope.currentRobotHasPermission = resp['permissions'].length > 0;
$scope.state = $scope.currentRobotHasPermission ? 'ready' : 'perm-error';
});
};
$scope.$watch('pullEntity', checkEntity);
$scope.$watch('reset', function(reset) {
if (reset) {
$scope.state = 'empty';
$scope.pullEntity = null;
$scope.privateBaseRepository = null;
}
});
$scope.$watch('state', function(state) {
$scope.isReady = state == 'ready';
if ($scope.isReady) {
$scope.readyForBuild({
'startBuild': startBuild
});
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,60 @@
.label.FROM {
border-color: #5bc0de !important;
}
.label.ARG {
border-color: #eaef4d !important;
}
.label.ONBUILD {
border-color: #6813d8 !important;
}
.label.CMD, .label.EXPOSE, .label.ENTRYPOINT {
border-color: #428bca !important;
}
.label.RUN, .label.ADD, .label.COPY {
border-color: #5cb85c !important;
}
.label.ENV, .label.VOLUME, .label.USER, .label.WORKDIR, .label.HEALTHCHECK, .label.STOPSIGNAL, .label.SHELL {
border-color: #f0ad4e !important;
}
.label.MAINTAINER {
border-color: #aaa !important;
}
.dockerfile-command {
display: block;
position: relative;
padding-left: 96px;
}
.dockerfile-command .command-title {
font-family: Consolas, "Lucida Console", Monaco, monospace !important;
display: inline-block;
}
.dockerfile-command .command-title a {
color: #5bc0de;
font-size: 15px;
}
.dockerfile-command .label {
color: white;
padding-top: 4px;
text-align: right;
margin-right: 4px;
width: 86px;
display: inline-block;
border-right: 4px solid #aaa;
background-color: #333;
position: absolute;
top: 2px;
left: 0px;
}

View file

@ -0,0 +1,7 @@
<span class="dockerfile-command dockerfile-command-element">
<span class="label" ng-class="::$ctrl.getCommandKind($ctrl.command)"
ng-if="::$ctrl.getCommandKind($ctrl.command)">
{{ ::$ctrl.getCommandKind($ctrl.command) }}
</span>
<span class="command-title" ng-bind-html="::$ctrl.getCommandTitleHtml($ctrl.command)"></span>
</span>

View file

@ -0,0 +1,75 @@
import { Input, Component, Inject } from 'ng-metadata/core';
import './dockerfile-command.component.css';
/**
* A component which displays a Dockerfile command, nicely formatted.
*/
@Component({
selector: 'dockerfile-command',
templateUrl: '/static/js/directives/ui/dockerfile-command/dockerfile-command.component.html',
})
export class DockerfileCommandComponent {
@Input('<') public command: string;
private registryHandlers: {[domain: string]: Function};
constructor (@Inject('Config') Config: any, @Inject('UtilService') private utilService: any) {
var registryHandlers = {
'quay.io': function(pieces) {
var rnamespace = pieces[pieces.length - 2];
var rname = pieces[pieces.length - 1].split(':')[0];
return '/repository/' + rnamespace + '/' + rname + '/';
},
'': function(pieces) {
var rnamespace = pieces.length == 1 ? '_' : 'u/' + pieces[0];
var rname = pieces[pieces.length - 1].split(':')[0];
return 'https://registry.hub.docker.com/' + rnamespace + '/' + rname + '/';
}
};
registryHandlers[Config.getDomain()] = registryHandlers['quay.io'];
this.registryHandlers = registryHandlers;
}
private getCommandKind(command: string): string {
command = command.trim();
if (!command) { return ''; }
var space = command.indexOf(' ');
return command.substring(0, space);
}
private getCommandTitleHtml(command: string): string {
command = command.trim();
if (!command) { return ''; }
var kindHandlers = {
'FROM': (command) => {
var parts = command.split(' ');
var pieces = parts[0].split('/');
var registry = pieces.length < 3 ? '' : pieces[0];
if (!this.registryHandlers[registry]) {
return command;
}
return '<a href="' + this.registryHandlers[registry](pieces) + '" target="_blank">' + parts[0] + '</a> ' + (parts.splice(1).join(' '));
}
};
var space = command.indexOf(' ');
if (space <= 0) {
return this.utilService.textToSafeHtml(command);
}
var kind = this.getCommandKind(command);
var sanitized = this.utilService.textToSafeHtml(command.substring(space + 1));
var handler = kindHandlers[kind || ''];
if (handler) {
return handler(sanitized);
} else {
return sanitized;
}
}
}

View file

@ -0,0 +1,38 @@
<div class="dockerfile-path-select-element">
<div class="dropdown-select"
placeholder="'Enter path containing a Dockerfile'"
selected-item="$ctrl.selectedPath"
lookahead-items="$ctrl.paths"
handle-input="$ctrl.setPath(input)"
handle-item-selected="$ctrl.setSelectedPath(datum.value)"
allow-custom-input="true"
hide-dropdown="!$ctrl.supportsFullListing">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg"
ng-show="$ctrl.isUnknownPath"></i>
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;"
ng-show="!$ctrl.isUnknownPath"></i>
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu pull-right" role="menu">
<li ng-repeat="path in $ctrl.paths">
<a ng-click="$ctrl.setSelectedPath(path)"
ng-if="path">
<i class="fa fa-folder fa-lg"></i> {{ path }}
</a>
</li>
<li class="dropdown-header" role="presentation"
ng-show="!$ctrl.paths.length">
No Dockerfiles found in repository
</li>
</ul>
</div>
<div style="padding: 10px">
<div class="co-alert co-alert-danger"
ng-show="!$ctrl.isValidPath && $ctrl.currentPath">
Path entered for folder containing Dockerfile is invalid: Must start with a '/'.
</div>
</div>
</div>

View file

@ -0,0 +1,110 @@
import { DockerfilePathSelectComponent, PathChangeEvent } from './dockerfile-path-select.component';
describe("DockerfilePathSelectComponent", () => {
var component: DockerfilePathSelectComponent;
var currentPath: string;
var isValidPath: boolean;
var paths: string[];
var supportsFullListing: boolean;
beforeEach(() => {
component = new DockerfilePathSelectComponent();
currentPath = '/';
isValidPath = false;
paths = ['/'];
supportsFullListing = true;
component.currentPath = currentPath;
component.isValidPath = isValidPath;
component.paths = paths;
component.supportsFullListing = supportsFullListing;
});
describe("ngOnChanges", () => {
it("sets valid path flag to true if current path is valid", () => {
component.ngOnChanges({});
expect(component.isValidPath).toBe(true);
});
it("sets valid path flag to false if current path is invalid", () => {
component.currentPath = "asdfdsf";
component.ngOnChanges({});
expect(component.isValidPath).toBe(false);
});
});
describe("setPath", () => {
var newPath: string;
beforeEach(() => {
newPath = '/conf';
});
it("sets current path to given path", () => {
component.setPath(newPath);
expect(component.currentPath).toEqual(newPath);
});
it("sets valid path flag to true if given path is valid", () => {
component.setPath(newPath);
expect(component.isValidPath).toBe(true);
});
it("sets valid path flag to false if given path is invalid", () => {
component.setPath("asdfsadfs");
expect(component.isValidPath).toBe(false);
});
it("emits output event indicating Dockerfile path has changed", (done) => {
component.pathChanged.subscribe((event: PathChangeEvent) => {
expect(event.path).toEqual(newPath);
expect(event.isValid).toBe(component.isValidPath);
done();
});
component.setPath(newPath);
});
});
describe("setCurrentPath", () => {
var newPath: string;
beforeEach(() => {
newPath = '/conf';
});
it("sets current path to given path", () => {
component.setSelectedPath(newPath);
expect(component.currentPath).toEqual(newPath);
});
it("sets valid path flag to true if given path is valid", () => {
component.setSelectedPath(newPath);
expect(component.isValidPath).toBe(true);
});
it("sets valid path flag to false if given path is invalid", () => {
component.setSelectedPath("a;lskjdf;ldsa");
expect(component.isValidPath).toBe(false);
});
it("emits output event indicating Dockerfile path has changed", (done) => {
component.pathChanged.subscribe((event: PathChangeEvent) => {
expect(event.path).toEqual(newPath);
expect(event.isValid).toBe(component.isValidPath);
done();
});
component.setSelectedPath(newPath);
});
});
});

View file

@ -0,0 +1,60 @@
import { Input, Output, EventEmitter, Component, OnChanges, SimpleChanges } from 'ng-metadata/core';
/**
* A component that allows the user to select the location of the Dockerfile in their source code repository.
*/
@Component({
selector: 'dockerfile-path-select',
templateUrl: '/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.html'
})
export class DockerfilePathSelectComponent implements OnChanges {
@Input('<') public currentPath: string = '';
@Input('<') public paths: string[];
@Input('<') public supportsFullListing: boolean;
@Output() public pathChanged: EventEmitter<PathChangeEvent> = new EventEmitter();
public isValidPath: boolean;
private isUnknownPath: boolean = true;
private selectedPath: string | null = null;
public ngOnChanges(changes: SimpleChanges): void {
this.isValidPath = this.checkPath(this.currentPath, this.paths, this.supportsFullListing);
}
public setPath(path: string): void {
this.currentPath = path;
this.selectedPath = null;
this.isValidPath = this.checkPath(path, this.paths, this.supportsFullListing);
this.pathChanged.emit({path: this.currentPath, isValid: this.isValidPath});
}
public setSelectedPath(path: string): void {
this.currentPath = path;
this.selectedPath = path;
this.isValidPath = this.checkPath(path, this.paths, this.supportsFullListing);
this.pathChanged.emit({path: this.currentPath, isValid: this.isValidPath});
}
private checkPath(path: string = '', paths: string[] = [], supportsFullListing: boolean): boolean {
this.isUnknownPath = false;
var isValidPath: boolean = false;
if (path != null && path.length > 0 && path[0] === '/') {
isValidPath = true;
this.isUnknownPath = supportsFullListing && paths.indexOf(path) < 0;
}
return isValidPath;
}
}
/**
* Dockerfile path changed event.
*/
export type PathChangeEvent = {
path: string;
isValid: boolean;
};

View file

@ -0,0 +1,94 @@
/**
* An element which displays a donut chart of data.
*/
angular.module('quay').directive('donutChart', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/donut-chart.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'width': '@width',
'minPercent': '@minPercent',
'data': '=data',
},
controller: function($scope, $element) {
$scope.created = false;
// Based on: http://bl.ocks.org/erichoco/6694616
var chart = d3.select($element.find('.donut-chart-element')[0]);
var renderChart = function() {
if (!$scope.data || !$scope.data.length) {
return;
}
// Adjust the data to make sure each non-zero entry is at minimum. While technically
// not accurate, it will make the section visible to the user.
var adjustedData = []
var total = 0;
$scope.data.map(function(entry) {
total += entry.value;
});
adjustedData = $scope.data.map(function(entry) {
var value = entry.value;
if ($scope.minPercent) {
if (value / total < $scope.minPercent / 100) {
value = total * $scope.minPercent / 100;
}
}
var copy = $.extend({}, entry);
copy.value = value;
return copy;
});
var $chart = $element.find('.donut-chart-element');
$chart.empty();
var width = $scope.width * 1;
var chart_m = width / 2 * 0.14;
var chart_r = width / 2 * 0.85;
var topG = chart.append('svg:svg')
.attr('width', (chart_r + chart_m) * 2)
.attr('height', (chart_r + chart_m) * 2)
.append('svg:g')
.attr('class', 'donut')
.attr('transform', 'translate(' + (chart_r + chart_m) + ',' +
(chart_r + chart_m) + ')');
var arc = d3.svg.arc()
.innerRadius(chart_r * 0.6)
.outerRadius(function(d, i) {
return i == adjustedData.length - 1 ? chart_r * 1.2 : chart_r * 1;
});
var pie = d3.layout.pie()
.sort(null)
.value(function(d) {
return d.value;
});
var reversed = adjustedData.reverse();
var g = topG.selectAll(".arc")
.data(pie(reversed))
.enter().append("g")
.attr("class", "arc");
g.append("path")
.attr("d", arc)
.style('stroke', '#fff')
.style("fill", function(d) { return d.data.color; });
};
$scope.$watch('data', renderChart);
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,118 @@
/**
* An element which displays a dropdown select box which is (optionally) editable. This box
* is displayed with an <input> and a menu on the right.
*/
angular.module('quay').directive('dropdownSelectDirect', function ($compile) {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/dropdown-select-direct.html',
replace: true,
transclude: true,
restrict: 'C',
scope: {
'selectedItem': '=selectedItem',
'placeholder': '=placeholder',
'items': '=items',
'iconMap': '=iconMap',
'valueKey': '@valueKey',
'titleKey': '@titleKey',
'iconKey': '@iconKey',
'noneIcon': '@noneIcon',
'clearValue': '=clearValue'
},
controller: function($scope, $element, $rootScope) {
if (!$rootScope.__dropdownSelectCounter) {
$rootScope.__dropdownSelectCounter = 1;
}
$scope.placeholder = $scope.placeholder || '';
$scope.internalItem = null;
// Setup lookahead.
var input = $($element).find('.lookahead-input');
$scope.setItem = function(item) {
$scope.selectedItem = item;
};
$scope.$watch('clearValue', function(cv) {
if (cv) {
$scope.selectedItem = null;
$(input).val('');
}
});
$scope.$watch('selectedItem', function(item) {
if ($scope.selectedItem == $scope.internalItem) {
// The item has already been set due to an internal action.
return;
}
if ($scope.selectedItem != null) {
$(input).val(item[$scope.valueKey]);
} else {
$(input).val('');
}
});
$scope.$watch('items', function(items) {
$(input).off();
if (!items || !$scope.valueKey) {
return;
}
var formattedItems = [];
for (var i = 0; i < items.length; ++i) {
var currentItem = items[i];
var formattedItem = {
'value': currentItem[$scope.valueKey],
'item': currentItem
};
formattedItems.push(formattedItem);
}
var dropdownHound = new Bloodhound({
name: 'dropdown-items-' + $rootScope.__dropdownSelectCounter,
local: formattedItems,
datumTokenizer: function(d) {
return Bloodhound.tokenizers.whitespace(d.val || d.value || '');
},
queryTokenizer: Bloodhound.tokenizers.whitespace
});
dropdownHound.initialize();
$(input).typeahead({}, {
source: dropdownHound.ttAdapter(),
templates: {
'suggestion': function (datum) {
template = datum['template'] ? datum['template'](datum) : datum['value'];
return template;
}
}
});
$(input).on('input', function(e) {
$scope.$apply(function() {
$scope.internalItem = null;
$scope.selectedItem = null;
});
});
$(input).on('typeahead:selected', function(e, datum) {
$scope.$apply(function() {
$scope.internalItem = datum['item'];
$scope.selectedItem = datum['item'];
});
});
$rootScope.__dropdownSelectCounter++;
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,170 @@
/**
* An element which displays a dropdown select box which is (optionally) editable. This box
* is displayed with an <input> and a menu on the right.
*/
angular.module('quay').directive('dropdownSelect', function ($compile) {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/dropdown-select.html',
replace: true,
transclude: true,
restrict: 'C',
scope: {
'selectedItem': '=selectedItem',
'placeholder': '=placeholder',
'lookaheadItems': '=lookaheadItems',
'hideDropdown': '=hideDropdown',
'allowCustomInput': '@allowCustomInput',
'handleItemSelected': '&handleItemSelected',
'handleInput': '&handleInput',
'clearValue': '=clearValue'
},
controller: function($scope, $element, $rootScope) {
if (!$rootScope.__dropdownSelectCounter) {
$rootScope.__dropdownSelectCounter = 1;
}
$scope.placeholder = $scope.placeholder || '';
$scope.lookaheadSetup = false;
// Setup lookahead.
var input = $($element).find('.lookahead-input');
$scope.$watch('clearValue', function(cv) {
if (cv && $scope.lookaheadSetup) {
$scope.selectedItem = null;
$(input).typeahead('val', '');
$(input).typeahead('close');
}
});
$scope.$watch('selectedItem', function(item) {
if (item != null && $scope.lookaheadSetup) {
$(input).typeahead('val', item.toString());
$(input).typeahead('close');
}
});
$scope.$watch('lookaheadItems', function(items) {
$(input).off();
items = items || [];
var formattedItems = [];
for (var i = 0; i < items.length; ++i) {
var formattedItem = items[i];
if (typeof formattedItem == 'string') {
formattedItem = {
'value': formattedItem
};
}
formattedItems.push(formattedItem);
}
var dropdownHound = new Bloodhound({
name: 'dropdown-items-' + $rootScope.__dropdownSelectCounter,
local: formattedItems,
datumTokenizer: function(d) {
return Bloodhound.tokenizers.whitespace(d.val || d.value || '');
},
queryTokenizer: Bloodhound.tokenizers.whitespace
});
dropdownHound.initialize();
$(input).typeahead({
'hint': false,
'highlight': false
}, {
source: dropdownHound.ttAdapter(),
templates: {
'suggestion': function (datum) {
template = datum['template'] ? datum['template'](datum) : '<span>' + datum['value'] + '</span>';
return template;
}
}
});
$(input).on('input', function(e) {
$scope.$apply(function() {
$scope.selectedItem = null;
if ($scope.handleInput) {
$scope.handleInput({'input': $(input).val()});
}
});
});
$(input).on('typeahead:selected', function(e, datum) {
$scope.$apply(function() {
$scope.selectedItem = datum['item'] || datum['value'];
if ($scope.handleItemSelected) {
$scope.handleItemSelected({'datum': datum});
}
});
});
$rootScope.__dropdownSelectCounter++;
$scope.lookaheadSetup = true;
});
},
link: function(scope, element, attrs) {
var transcludedBlock = element.find('div.transcluded');
var transcludedElements = transcludedBlock.children();
var iconContainer = element.find('div.dropdown-select-icon-transclude');
var menuContainer = element.find('div.dropdown-select-menu-transclude');
angular.forEach(transcludedElements, function(elem) {
if (angular.element(elem).hasClass('dropdown-select-icon')) {
iconContainer.append(elem);
} else if (angular.element(elem).hasClass('dropdown-select-menu')) {
menuContainer.replaceWith(elem);
}
});
transcludedBlock.remove();
}
};
return directiveDefinitionObject;
});
/**
* An icon in the dropdown select. Only one icon will be displayed at a time.
*/
angular.module('quay').directive('dropdownSelectIcon', function () {
var directiveDefinitionObject = {
priority: 1,
require: '^dropdownSelect',
templateUrl: '/static/directives/dropdown-select-icon.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});
/**
* The menu for the dropdown select.
*/
angular.module('quay').directive('dropdownSelectMenu', function () {
var directiveDefinitionObject = {
priority: 1,
require: '^dropdownSelect',
templateUrl: '/static/directives/dropdown-select-menu.html',
replace: true,
transclude: true,
restrict: 'C',
scope: {
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,4 @@
<div class="duration-input-element">
<range-slider ng-range-min="$ctrl.min_s" ng-range-max="$ctrl.max_s" ng-model="$ctrl.seconds"></range-slider>
<span class="duration-explanation">{{ $ctrl.durationExplanation($ctrl.seconds) }}</span>
</div>

View file

@ -0,0 +1,59 @@
import { Input, Component, Inject } from 'ng-metadata/core';
import * as moment from "moment";
/**
* A component that allows for selecting a time duration.
*/
@Component({
selector: 'duration-input',
templateUrl: '/static/js/directives/ui/duration-input/duration-input.component.html'
})
export class DurationInputComponent implements ng.IComponentController {
@Input('<') public min: string;
@Input('<') public max: string;
@Input('=?') public value: string;
@Input('=?') public seconds: number;
private min_s: number;
private max_s: number;
constructor(@Inject('$scope') private $scope: ng.IScope) {
}
public $onInit(): void {
// TODO: replace this.
this.$scope.$watch(() => this.seconds, this.updateValue.bind(this));
this.refresh();
}
public $onChanges(changes: ng.IOnChangesObject): void {
this.refresh();
}
private updateValue(): void {
this.value = `${this.seconds}s`;
}
private refresh(): void {
this.min_s = this.toSeconds(this.min || '0s');
this.max_s = this.toSeconds(this.max || '1h');
if (this.value) {
this.seconds = this.toSeconds(this.value || '0s');
}
}
private durationExplanation(durationSeconds: string): string {
return moment.duration(parseInt(durationSeconds), 's').humanize();
}
private toSeconds(durationStr: string): number {
var number = durationStr.substring(0, durationStr.length - 1);
var suffix = durationStr.substring(durationStr.length - 1);
return moment.duration(parseInt(number), <moment.unitOfTime.Base>suffix).asSeconds();
}
}

View file

@ -0,0 +1,60 @@
/**
* An element which shows an icon and a name/title for an entity (user, org, robot, team),
* optionally linking to that entity if applicable.
*/
angular.module('quay').directive('entityReference', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/entity-reference.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'entity': '=entity',
'namespace': '=namespace',
'showAvatar': '@showAvatar',
'avatarSize': '@avatarSize'
},
controller: function($scope, $element, UserService, UtilService, Config) {
$scope.robotToShow = null;
$scope.getIsAdmin = function(namespace) {
return UserService.isNamespaceAdmin(namespace);
};
$scope.getTitle = function(entity) {
if (!entity) { return ''; }
switch (entity.kind) {
case 'org':
return 'Organization';
case 'team':
return 'Team';
case 'user':
return entity.is_robot ? 'Robot Account' : 'User';
}
};
$scope.getPrefix = function(name) {
if (!name) { return ''; }
var plus = name.indexOf('+');
return name.substr(0, plus);
};
$scope.getShortenedName = function(name) {
if (!name) { return ''; }
var plus = name.indexOf('+');
return name.substr(plus + 1);
};
$scope.showRobotCredentials = function() {
$scope.robotToShow = {
'name': $scope.entity.name
};
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,432 @@
/**
* An element which displays a box to search for an entity (org, user, robot, team). This control
* allows for filtering of the entities found and whether to allow selection by e-mail.
*/
angular.module('quay').directive('entitySearch', function () {
var number = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/entity-search.html',
replace: false,
transclude: false,
restrict: 'C',
require: '?ngModel',
link: function(scope, element, attr, ctrl) {
scope.ngModel = ctrl;
},
scope: {
'namespace': '=namespace',
'placeholder': '=placeholder',
'forRepository': '=forRepository',
'skipPermissions': '=skipPermissions',
// Default: ['user', 'team', 'robot']
'allowedEntities': '=allowedEntities',
'currentEntity': '=currentEntity',
'entitySelected': '&entitySelected',
'emailSelected': '&emailSelected',
// When set to true, the contents of the control will be cleared as soon
// as an entity is selected.
'autoClear': '=autoClear',
// Set this property to immediately clear the contents of the control.
'clearValue': '=clearValue',
// Whether e-mail addresses are allowed.
'allowEmails': '=allowEmails',
'emailMessage': '@emailMessage',
// True if the menu should pull right.
'pullRight': '@pullRight'
},
controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService, UtilService, AvatarService, Config, StateService) {
$scope.inReadOnlyMode = StateService.inReadOnlyMode();
$scope.requiresLazyLoading = true;
$scope.isLazyLoading = false;
$scope.userRequestedLazyLoading = false;
$scope.teams = null;
$scope.page = {};
$scope.page.robots = null;
$scope.isAdmin = false;
$scope.isOrganization = false;
$scope.includeTeams = true;
$scope.includeRobots = true;
$scope.includeOrgs = false;
$scope.currentEntityInternal = $scope.currentEntity;
$scope.createRobotInfo = null;
$scope.createTeamInfo = null;
$scope.Config = Config;
var isSupported = function(kind, opt_array) {
return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0;
};
var resetCache = function() {
$scope.requiresLazyLoading = true;
$scope.teams = null;
$scope.page.robots = null;
};
$scope.lazyLoad = function() {
$scope.userRequestedLazyLoading = true;
$scope.checkLazyLoad();
};
$scope.checkLazyLoad = function() {
if (!$scope.namespace || !$scope.thisUser || !$scope.requiresLazyLoading ||
$scope.isLazyLoading || !$scope.userRequestedLazyLoading) {
return;
}
$scope.isLazyLoading = true;
$scope.isAdmin = UserService.isNamespaceAdmin($scope.namespace);
$scope.isOrganization = !!UserService.getOrganization($scope.namespace);
// Reset the cached teams and robots, just to be sure.
$scope.teams = null;
$scope.page.robots = null;
var requiredOperations = 0;
var operationComplete = function() {
requiredOperations--;
if (requiredOperations <= 0) {
$scope.isLazyLoading = false;
$scope.requiresLazyLoading = false;
}
};
// Load the organization's teams (if applicable).
if ($scope.isOrganization && isSupported('team')) {
requiredOperations++;
// Note: We load the org here directly so that we always have the fully up-to-date
// teams list.
ApiService.getOrganization(null, {'orgname': $scope.namespace}).then(function(resp) {
$scope.teams = Object.keys(resp.teams).map(function(key) {
return resp.teams[key];
});
operationComplete();
}, operationComplete);
}
// Load the user/organization's robots (if applicable).
if ($scope.isAdmin && isSupported('robot')) {
requiredOperations++;
var params = {
'token': false,
'limit': 20
};
ApiService.getRobots($scope.isOrganization ? $scope.namespace : null, null, params).then(function(resp) {
$scope.page.robots = resp.robots;
operationComplete();
}, operationComplete);
}
if (requiredOperations == 0) {
operationComplete();
}
};
$scope.askCreateTeam = function() {
$scope.createTeamInfo = {
'namespace': $scope.namespace,
'repository': $scope.forRepository,
'skip_permissions': $scope.skipPermissions
};
};
$scope.askCreateRobot = function() {
$scope.createRobotInfo = {
'namespace': $scope.namespace,
'repository': $scope.forRepository,
'skip_permissions': $scope.skipPermissions
};
};
$scope.handleTeamCreated = function(created) {
$scope.setEntity(created.name, 'team', false, created.avatar);
if (created.new_team) {
$scope.teams.push(created);
}
};
$scope.handleRobotCreated = function(created) {
$scope.setEntity(created.name, 'user', true, created.avatar);
$scope.page.robots.push(created);
};
$scope.setEntity = function(name, kind, is_robot, avatar) {
var entity = {
'name': name,
'kind': kind,
'is_robot': is_robot,
'avatar': avatar
};
if ($scope.isOrganization) {
entity['is_org_member'] = true;
}
$scope.setEntityInternal(entity, false);
};
$scope.clearEntityInternal = function() {
$scope.currentEntityInternal = null;
$scope.currentEntity = null;
$scope.entitySelected({'entity': null});
if ($scope.ngModel) {
$scope.ngModel.$setValidity('entity', false);
}
};
$scope.setEntityInternal = function(entity, updateTypeahead) {
// If the entity is an external entity, convert it to a known user via an API call.
if (entity.kind == 'external') {
var params = {
'username': entity.name
};
ApiService.linkExternalUser(null, params).then(function(resp) {
$scope.setEntityInternal(resp['entity'], updateTypeahead);
}, ApiService.errorDisplay('Could not link external user'));
return;
}
if (updateTypeahead) {
$(input).typeahead('val', $scope.autoClear ? '' : entity.name);
} else {
$(input).val($scope.autoClear ? '' : entity.name);
}
if (!$scope.autoClear) {
$scope.currentEntityInternal = entity;
$scope.currentEntity = entity;
}
$scope.entitySelected({'entity': entity});
if ($scope.ngModel) {
$scope.ngModel.$setValidity('entity', !!entity);
}
};
// Setup the typeahead.
var input = $element[0].firstChild.firstChild;
(function() {
// Create the bloodhound search query system.
$rootScope.__entity_search_counter = (($rootScope.__entity_search_counter || 0) + 1);
var entitySearchB = new Bloodhound({
name: 'entities' + $rootScope.__entity_search_counter,
remote: {
url: '/api/v1/entities/%QUERY',
replace: function (query_url, uriEncodedQuery) {
$scope.lazyLoad();
var namespace = $scope.namespace || '';
var url = UtilService.getRestUrl(query_url.replace('%QUERY', uriEncodedQuery));
url.setQueryParameter('namespace', namespace);
if ($scope.isOrganization && isSupported('team')) {
url.setQueryParameter('includeTeams', true);
}
if (isSupported('org')) {
url.setQueryParameter('includeOrgs', true);
}
return url;
},
filter: function(data) {
var datums = [];
for (var i = 0; i < data.results.length; ++i) {
var entity = data.results[i];
var found = 'user';
if (entity.kind == 'user' || entity.kind == 'external') {
found = entity.is_robot ? 'robot' : 'user';
} else if (entity.kind == 'team') {
found = 'team';
} else if (entity.kind == 'org') {
found = 'org';
}
if (!isSupported(found)) {
continue;
}
datums.push({
'value': entity.name,
'tokens': [entity.name],
'entity': entity
});
}
return datums;
}
},
datumTokenizer: function(d) {
return Bloodhound.tokenizers.whitespace(d.val);
},
queryTokenizer: Bloodhound.tokenizers.whitespace
});
entitySearchB.initialize();
// Setup the typeahead.
$(input).typeahead({
'highlight': true,
'hint': false,
}, {
display: 'value',
source: entitySearchB.ttAdapter(),
templates: {
'notFound': function(info) {
// Only display the empty dialog if the server load has finished.
if (info.resultKind == 'remote') {
var val = $(input).val();
if (!val) {
return null;
}
if (UtilService.isEmailAddress(val)) {
if ($scope.allowEmails) {
return '<div class="tt-message">' + $scope.emailMessage + '</div>';
} else {
return '<div class="tt-empty">A ' + Config.REGISTRY_TITLE_SHORT + ' username (not an e-mail address) must be specified</div>';
}
}
var classes = [];
if (isSupported('user')) { classes.push('users'); }
if (isSupported('org')) { classes.push('organizations'); }
if ($scope.isAdmin && isSupported('robot')) { classes.push('robot accounts'); }
if ($scope.isOrganization && isSupported('team')) { classes.push('teams'); }
if (classes.length == 0) {
return '<div class="tt-empty">No matching entities found</div>';
}
var class_string = '';
for (var i = 0; i < classes.length; ++i) {
if (i > 0) {
if (i == classes.length - 1) {
class_string += ' or ';
} else {
class_string += ', ';
}
}
class_string += classes[i];
}
return '<div class="tt-empty">No matching ' + Config.REGISTRY_TITLE_SHORT + ' ' + class_string + ' found</div>';
}
return null;
},
'suggestion': function (datum) {
template = '<div class="entity-mini-listing">';
if (Config['AVATAR_KIND'] === 'gravatar' &&
((datum.entity.kind == 'user' && !datum.entity.is_robot) || (datum.entity.kind == 'org'))) {
template += '<i class="fa"><img class="avatar-image" src="' +
AvatarService.getAvatar(datum.entity.avatar.hash, 20, 'mm') +
'"></i>';
} else if (datum.entity.kind == 'external') {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
template += '<i class="fa ci-robot fa-lg"></i>';
} else if (datum.entity.kind == 'team') {
template += '<i class="fa fa-group fa-lg"></i>';
}
template += '<span class="name">' + datum.value + '</span>';
if (datum.entity.title) {
template += '<span class="title">' + datum.entity.title + '</span>';
}
if (datum.entity.is_org_member === false && datum.entity.kind == 'user') {
template += '<i class="fa fa-exclamation-triangle" title="User is outside the organization"></i>';
}
template += '</div>';
return template;
}}
});
$(input).on('keypress', function(e) {
var val = $(input).val();
var code = e.keyCode || e.which;
if (code == 13 && $scope.allowEmails && UtilService.isEmailAddress(val)) {
$scope.$apply(function() {
$scope.emailSelected({'email': val});
});
}
});
$(input).on('input', function(e) {
$scope.$apply(function() {
$scope.clearEntityInternal();
});
});
$(input).on('typeahead:selected', function(e, datum) {
$scope.$apply(function() {
$scope.setEntityInternal(datum.entity, true);
});
});
})();
$scope.$watch('clearValue', function() {
if (!input) { return; }
$(input).typeahead('val', '');
$scope.clearEntityInternal();
});
$scope.$watch('placeholder', function(title) {
input.setAttribute('placeholder', title);
});
$scope.$watch('allowedEntities', function(allowed) {
if (!allowed) { return; }
$scope.includeTeams = isSupported('team', allowed);
$scope.includeRobots = isSupported('robot', allowed);
});
$scope.$watch('namespace', function(namespace) {
if (!namespace) { return; }
resetCache();
$scope.checkLazyLoad();
});
UserService.updateUserIn($scope, function(currentUser){
if (currentUser.anonymous) { return; }
$scope.thisUser = currentUser;
resetCache();
$scope.checkLazyLoad();
});
$scope.$watch('currentEntity', function(entity) {
if ($scope.currentEntityInternal != entity) {
if (entity) {
$scope.setEntityInternal(entity, false);
} else {
$scope.clearEntityInternal();
}
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,23 @@
.expiration-status-view-element .expired, .expiration-status-view-element .expired a {
color: #D64456;
}
.expiration-status-view-element .critical, .expiration-status-view-element .critical a {
color: #F77454;
}
.expiration-status-view-element .warning, .expiration-status-view-element .warning a {
color: #FCA657;
}
.expiration-status-view-element .info, .expiration-status-view-element .info a {
color: #2FC98E;
}
.expiration-status-view-element .no-expiration, .expiration-status-view-element .no-expiration a {
color: #aaa;
}
.expiration-status-view-element .fa {
margin-right: 6px;
}

View file

@ -0,0 +1,10 @@
<span class="expiration-status-view-element">
<span ng-if="::$ctrl.expirationDate" ng-class="::$ctrl.getExpirationInfo($ctrl.expirationDate).className">
<i class="fa" ng-class="::$ctrl.getExpirationInfo($ctrl.expirationDate).icon"></i>
<time-ago datetime="$ctrl.expirationDate"></time-ago>
</a>
</span>
<span class="no-expiration" ng-if="::!$ctrl.expirationDate">
Never
</span>
</span>

View file

@ -0,0 +1,40 @@
import { Input, Component, Inject } from 'ng-metadata/core';
import * as moment from "moment";
import './expiration-status-view.component.css';
type expirationInfo = {
className: string;
icon: string;
};
/**
* A component that displays expiration status.
*/
@Component({
selector: 'expiration-status-view',
templateUrl: '/static/js/directives/ui/expiration-status-view/expiration-status-view.component.html',
})
export class ExpirationStatusViewComponent {
@Input('<') public expirationDate: Date;
private getExpirationInfo(expirationDate): expirationInfo|null {
if (!expirationDate) {
return null;
}
const expiration = moment(expirationDate);
if (moment().isAfter(expiration)) {
return {'className': 'expired', 'icon': 'fa-warning'};
}
if (moment().add(1, 'week').isAfter(expiration)) {
return {'className': 'critical', 'icon': 'fa-warning'};
}
if (moment().add(1, 'month').isAfter(expiration)) {
return {'className': 'warning', 'icon': 'fa-warning'};
}
return {'className': 'info', 'icon': 'fa-clock-o'};
}
}

View file

@ -0,0 +1,39 @@
/**
* An element which displays a button for logging into the application via an external service.
*/
angular.module('quay').directive('externalLoginButton', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/external-login-button.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'signInStarted': '&signInStarted',
'redirectUrl': '=redirectUrl',
'isLink': '=isLink',
'provider': '=provider',
'action': '@action'
},
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, ExternalLoginService) {
$scope.signingIn = false;
$scope.startSignin = function() {
$scope.signInStarted({'service': $scope.provider});
ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) {
// Save the redirect URL in a cookie so that we can redirect back after the service returns to us.
var redirectURL = $scope.redirectUrl || window.location.toString();
CookieService.putPermanent('quay.redirectAfterLoad', redirectURL);
// Needed to ensure that UI work done by the started callback is finished before the location
// changes.
$scope.signingIn = true;
$timeout(function() {
document.location = url;
}, 250);
});
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,51 @@
/**
* Element for managing the applications authorized by a user.
*/
angular.module('quay').directive('externalLoginsManager', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/external-logins-manager.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'user': '=user',
},
controller: function($scope, $element, ApiService, UserService, Features, Config, KeyService,
ExternalLoginService) {
$scope.Features = Features;
$scope.Config = Config;
$scope.KeyService = KeyService;
$scope.EXTERNAL_LOGINS = ExternalLoginService.EXTERNAL_LOGINS;
$scope.externalLoginInfo = {};
$scope.hasSingleSignin = ExternalLoginService.hasSingleSignin();
UserService.updateUserIn($scope, function(user) {
$scope.cuser = jQuery.extend({}, user);
$scope.externalLoginInfo = {};
if ($scope.cuser.logins) {
for (var i = 0; i < $scope.cuser.logins.length; i++) {
var login = $scope.cuser.logins[i];
login.metadata = login.metadata || {};
$scope.externalLoginInfo[login.service] = login;
}
}
});
$scope.detachExternalLogin = function(service_id) {
if (!Features.DIRECT_LOGIN) { return; }
var params = {
'service_id': service_id
};
ApiService.detachExternalLogin(null, params).then(function() {
UserService.load();
}, ApiService.errorDisplay('Count not detach service'));
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,59 @@
/**
* DEPRECATED: An element which displays controls and information about a defined external notification on
* a repository.
*/
angular.module('quay').directive('externalNotificationView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/external-notification-view.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'notification': '=notification',
'notificationDeleted': '&notificationDeleted'
},
controller: function($scope, $element, ExternalNotificationData, ApiService) {
$scope.deleteNotification = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'uuid': $scope.notification.uuid
};
ApiService.deleteRepoNotification(null, params).then(function() {
$scope.notificationDeleted({'notification': $scope.notification});
});
};
$scope.testNotification = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'uuid': $scope.notification.uuid
};
ApiService.testRepoNotification(null, params).then(function() {
bootbox.dialog({
"title": "Test Notification Queued",
"message": "A test version of this notification has been queued and should appear shortly",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.$watch('notification', function(notification) {
if (notification) {
$scope.eventInfo = ExternalNotificationData.getEventInfo(notification.event);
$scope.methodInfo = ExternalNotificationData.getMethodInfo(notification.method);
$scope.config = notification.config;
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,37 @@
/**
* An element which displays a feedback bar when an action has been taken.
*/
angular.module('quay').directive('feedbackBar', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/feedback-bar.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'feedback': '=feedback'
},
controller: function($scope, $element, AvatarService, Config, UIService, $timeout, StringBuilderService) {
$scope.viewCounter = 0;
$scope.formattedMessage = '';
$scope.$watch('feedback', function(feedback) {
if (feedback) {
$scope.formattedMessage = StringBuilderService.buildTrustedString(feedback.message, feedback.data || {}, 'span');
$scope.viewCounter++;
} else {
$scope.viewCounter = 0;
}
});
$($element).find('.feedback-bar-element')
.on('webkitAnimationEnd oanimationend oAnimationEnd msAnimationEnd animationend',
function(e) {
$scope.$apply(function() {
$scope.viewCounter = 0;
});
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,143 @@
/**
* An element which adds a of dialog for fetching a tag.
*/
angular.module('quay').directive('fetchTagDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/fetch-tag-dialog.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'actionHandler': '=actionHandler'
},
controller: function($scope, $element, $timeout, ApiService, UserService, Config, Features) {
$scope.clearCounter = 0;
$scope.currentFormat = null;
$scope.currentEntity = null;
$scope.currentRobot = null;
$scope.formats = [];
$scope.currentRobotHasPermission = null;
UserService.updateUserIn($scope, updateFormats);
var updateFormats = function() {
$scope.formats = [];
$scope.formats.push({
'title': 'Docker Pull (by tag)',
'icon': 'docker-icon',
'command': 'docker pull {hostname}/{namespace}/{name}:{tag}'
});
if ($scope.currentTag && $scope.currentTag.manifest_digest) {
$scope.formats.push({
'title': 'Docker Pull (by digest)',
'icon': 'docker-icon',
'command': 'docker pull {hostname}/{namespace}/{name}@{manifest_digest}'
});
}
if ($scope.repository && $scope.currentTag && !$scope.currentTag.is_manifest_list) {
$scope.formats.push({
'title': 'Squashed Docker Image',
'icon': 'ci-squashed',
'command': 'curl -L -f {http}://{pull_user}:{pull_password}@{hostname}/c1/squash/{namespace}/{name}/{tag} | docker load',
'require_creds': true,
'has_creds': UserService.isNamespaceAdmin($scope.repository.namespace)
});
}
if (Features.ACI_CONVERSION && $scope.currentTag && !$scope.currentTag.is_manifest_list) {
$scope.formats.push({
'title': 'rkt Fetch',
'icon': 'rocket-icon',
'command': 'rkt fetch {hostname}/{namespace}/{name}:{tag}'
});
}
};
$scope.$watch('currentEntity', function(entity) {
if (!entity) {
$scope.currentRobot = null;
return;
}
if ($scope.currentRobot && $scope.currentRobot.name == entity.name) {
return;
}
$scope.currentRobot = null;
$scope.currentRobotHasPermission = null;
var parts = entity.name.split('+');
var namespace = parts[0];
var shortname = parts[1];
var params = {
'robot_shortname': shortname
};
var orgname = UserService.isOrganization(namespace) ? namespace : '';
ApiService.getRobot(orgname, null, params).then(function(resp) {
$scope.currentRobot = resp;
}, ApiService.errorDisplay('Cannot download robot token'));
var permParams = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'username': entity.name
};
ApiService.getUserTransitivePermission(null, permParams).then(function(resp) {
$scope.currentRobotHasPermission = resp['permissions'].length > 0;
});
});
$scope.getCommand = function(format, robot) {
if (!format || !format.command) { return ''; }
if (format.require_creds && !robot) { return ''; }
var params = {
'pull_user': robot ? robot.name : '',
'pull_password': robot ? robot.token : '',
'hostname': Config.getDomain(),
'http': Config.getHttp(),
'namespace': $scope.repository.namespace,
'name': $scope.repository.name,
'tag': $scope.currentTag.name,
'manifest_digest': $scope.currentTag.manifest_digest
};
var value = format.command;
for (var param in params) {
if (!params.hasOwnProperty(param)) { continue; }
value = value.replace('{' + param + '}', params[param]);
}
return value;
};
$scope.setFormat = function(format) {
$scope.currentFormat = format;
};
$scope.actionHandler = {
'askFetchTag': function(tag) {
$scope.currentTag = tag;
$scope.currentFormat = null;
$scope.currentEntity = null;
$scope.currentRobot = null;
$scope.currentRobotHasPermission = null;
$scope.clearCounter++;
updateFormats();
$element.find('#fetchTagDialog').modal({});
}
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,172 @@
/**
* An element which adds a stylize box for uploading a file.
*/
angular.module('quay').directive('fileUploadBox', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/file-upload-box.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'selectMessage': '@selectMessage',
'filesSelected': '&filesSelected',
'filesCleared': '&filesCleared',
'filesValidated': '&filesValidated',
'extensions': '<extensions',
'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, url, fileId, mimeType, progressCb, doneCb) {
var request = new XMLHttpRequest();
request.open('PUT', url, 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;
ApiService.getFiledropUrl(data).then(function(resp) {
// Perform the upload.
conductUpload(currentFile, resp.url, resp.file_id, mimeType, progressCb, doneCb);
}, function() {
callback(false, 'Could not retrieve upload URL');
});
};
// 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,21 @@
/**
* An element which displays a right-aligned control bar with an <input> for filtering a collection.
*/
angular.module('quay').directive('filterBox', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/filter-box.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'collection': '=collection',
'filterModel': '=filterModel',
'filterName': '@filterName'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,22 @@
/**
* An element which displays a link to change a lookup filter, and shows whether it is selected.
*/
angular.module('quay').directive('filterControl', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/filter-control.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'filter': '=filter',
'value': '@value'
},
controller: function($scope, $element) {
$scope.setFilter = function() {
$scope.filter = $scope.value;
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,102 @@
/**
* An element for managing global messages.
*/
angular.module('quay').directive('globalMessageTab', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/global-message-tab.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'isEnabled': '=isEnabled'
},
controller: function ($scope, $element, ApiService, StateService) {
$scope.inReadOnlyMode = StateService.inReadOnlyMode();
$scope.newMessage = {
'media_type': 'text/markdown',
'severity': 'info'
};
$scope.creatingMessage = false;
$scope.showCreateMessage = function () {
$scope.createdMessage = null;
$('#createMessageModal').modal('show');
};
$scope.createNewMessage = function () {
if (StateService.inReadOnlyMode()) {
return;
}
$scope.creatingMessage = true;
$scope.createdMessage = null;
var errorHandler = ApiService.errorDisplay('Cannot create message', function () {
$scope.creatingMessage = false;
$('#createMessageModal').modal('hide');
});
var data = {
'message': $scope.newMessage
};
ApiService.createGlobalMessage(data, null).then(function (resp) {
$scope.creatingMessage = false;
$('#createMessageModal').modal('hide');
$scope.loadMessageInternal();
}, errorHandler)
};
$scope.updateMessage = function(content) {
$scope.newMessage.content = content;
};
$scope.showDeleteMessage = function (uuid) {
if (StateService.inReadOnlyMode()) {
return;
}
$scope.messageToDelete = uuid;
$('#confirmDeleteMessageModal').modal({});
};
$scope.deleteMessage = function (uuid) {
if (StateService.inReadOnlyMode()) {
return;
}
$('#confirmDeleteMessageModal').modal('hide');
ApiService.deleteGlobalMessage(null, {uuid: uuid}).then(function (resp) {
$scope.loadMessageInternal();
}, ApiService.errorDisplay('Can not delete message'));
};
$scope.loadMessageOfTheDay = function () {
if ($scope.messages) {
return;
}
$scope.loadMessageInternal();
};
$scope.loadMessageInternal = function () {
ApiService.getGlobalMessages().then(function (resp) {
$scope.messages = resp['messages'];
}, function (resp) {
$scope.messages = [];
$scope.messagesErrors = ApiService.getErrorMessage(resp);
});
};
$scope.$watch('isEnabled', function (value) {
if (value) {
$scope.loadMessageInternal();
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,151 @@
/**
* The application header bar.
*/
angular.module('quay').directive('headerBar', function () {
var number = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/header-bar.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
},
controller: function($rootScope, $scope, $element, $location, $timeout, hotkeys, UserService,
PlanService, ApiService, NotificationService, Config, Features,
ExternalLoginService, StateService) {
ExternalLoginService.getSingleSigninUrl(function(url) {
$scope.externalSigninUrl = url;
});
var hotkeysAdded = false;
var userUpdated = function(cUser) {
$scope.searchingAllowed = Features.ANONYMOUS_ACCESS || !cUser.anonymous;
if (hotkeysAdded) { return; }
hotkeysAdded = true;
// Register hotkeys.
if (!cUser.anonymous) {
hotkeys.add({
combo: 'alt+c',
description: 'Create new repository',
callback: function(e) {
e.preventDefault();
e.stopPropagation();
$location.url('/new');
}
});
}
};
$scope.Config = Config;
$scope.Features = Features;
$scope.notificationService = NotificationService;
$scope.searchingAllowed = false;
$scope.showBuildDialogCounter = 0;
// Monitor any user changes and place the current user into the scope.
UserService.updateUserIn($scope, userUpdated);
StateService.updateStateIn($scope, function(state) {
$scope.inReadOnlyMode = state.inReadOnlyMode;
});
$scope.currentPageContext = {};
$rootScope.$watch('currentPage.scope.viewuser', function(u) {
$scope.currentPageContext['viewuser'] = u;
});
$rootScope.$watch('currentPage.scope.organization', function(o) {
$scope.currentPageContext['organization'] = o;
});
$rootScope.$watch('currentPage.scope.repository', function(r) {
$scope.currentPageContext['repository'] = r;
});
$scope.signout = function() {
ApiService.logout().then(function() {
UserService.load();
$location.path('/');
});
};
$scope.getEnterpriseLogo = function() {
return Config.getEnterpriseLogo();
};
$scope.getNamespace = function(context) {
if (!context) { return null; }
if (context.repository && context.repository.namespace) {
return context.repository.namespace;
}
if (context.organization && context.organization.name) {
return context.organization.name;
}
if (context.viewuser && context.viewuser.username) {
return context.viewuser.username;
}
return null;
};
$scope.canAdmin = function(namespace) {
if (!namespace) { return false; }
return UserService.isNamespaceAdmin(namespace);
};
$scope.isOrganization = function(namespace) {
if (!namespace) { return false; }
return UserService.isOrganization(namespace);
};
$scope.startBuild = function(context) {
$scope.showBuildDialogCounter++;
};
$scope.handleBuildStarted = function(build, context) {
$location.url('/repository/' + context.repository.namespace + '/' + context.repository.name + '/build/' + build.id);
};
$scope.handleRobotCreated = function(created, context) {
var namespace = $scope.getNamespace(context);
if (UserService.isOrganization(namespace)) {
$location.url('/organization/' + namespace + '?tab=robots&showRobot=' + created.name);
} else {
$location.url('/user/' + namespace + '?tab=robots&showRobot=' + created.name);
}
};
$scope.handleTeamCreated = function(created, context) {
var namespace = $scope.getNamespace(context);
$location.url('/organization/' + namespace + '/teams/' + created.name);
};
$scope.askCreateRobot = function(context) {
var namespace = $scope.getNamespace(context);
if (!namespace || !UserService.isNamespaceAdmin(namespace)) { return; }
$scope.createRobotInfo = {
'namespace': namespace
};
};
$scope.askCreateTeam = function(context) {
var namespace = $scope.getNamespace(context);
if (!namespace || !UserService.isNamespaceAdmin(namespace)) { return; }
$scope.createTeamInfo = {
'namespace': namespace
};
};
}
};
return directiveDefinitionObject;
});

Some files were not shown because too many files have changed in this diff Show more