1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

feat(extensions): introduce extension support (#2527)

* wip

* wip: missing repository & tags removal

* feat(registry): private registry management

* style(plugin-details): update view

* wip

* wip

* wip

* feat(plugins): add license info

* feat(plugins): browse feature preview

* feat(registry-configure): add the ability to configure registry management

* style(app): update text in app

* feat(plugins): add plugin version number

* feat(plugins): wip plugin upgrade process

* feat(plugins): wip plugin upgrade

* feat(plugins): add the ability to update a plugin

* feat(plugins): init plugins at startup time

* feat(plugins): add the ability to remove a plugin

* feat(plugins): update to latest plugin definitions

* feat(plugins): introduce plugin-tooltip component

* refactor(app): relocate plugin files to app/plugins

* feat(plugins): introduce PluginDefinitionsURL constant

* feat(plugins): update the flags used by the plugins

* feat(plugins): wip

* feat(plugins): display a label when a plugin has expired

* wip

* feat(registry-creation): update registry creation logic

* refactor(registry-creation): change name/ids for inputs

* feat(api): pass registry type to management configuration

* feat(api): unstrip /v2 in regsitry proxy

* docs(api): add TODO

* feat(store): mockup-1

* feat(store): mockup 2

* feat(store): mockup 2

* feat(store): update mockup-2

* feat(app): add unauthenticated event check

* update gruntfile

* style(support): update support views

* style(support): update product views

* refactor(extensions): refactor plugins to extensions

* feat(extensions): add a deal property

* feat(extensions): introduce ExtensionManager

* style(extensions): update extension details style

* feat(extensions): display license/company when enabling extension

* feat(extensions): update extensions views

* feat(extensions): use ProductId defined in extension schema

* style(app): remove padding left for form section title elements

* style(support): use per host model

* refactor(extensions): multiple refactors related to extensions mecanism

* feat(extensions): update tls file path for registry extension

* feat(extensions): update registry management configuration

* feat(extensions): send license in header to extension proxy

* fix(proxy): fix invalid default loopback address

* feat(extensions): add header X-RegistryManagement-ForceNew for specific operations

* feat(extensions): add the ability to display screenshots

* feat(extensions): center screenshots

* style(extensions): tune style

* feat(extensions-details): open full screen image on click (#2517)

* feat(extension-details): show magnifying glass on images

* feat(extensions): support extension logo

* feat(extensions): update support logos

* refactor(lint): fix lint issues
This commit is contained in:
Anthony Lapenna 2018-12-09 16:49:27 +13:00 committed by GitHub
parent f5dc663879
commit 6fd5ddc802
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 3519 additions and 268 deletions

View file

@ -0,0 +1,3 @@
angular.module('portainer.extensions', [
'portainer.extensions.registrymanagement'
]);

View file

@ -0,0 +1,41 @@
angular.module('portainer.extensions.registrymanagement', [])
.config(['$stateRegistryProvider', function ($stateRegistryProvider) {
'use strict';
var registryConfiguration = {
name: 'portainer.registries.registry.configure',
url: '/configure',
views: {
'content@': {
templateUrl: 'app/extensions/registry-management/views/configure/configureregistry.html',
controller: 'ConfigureRegistryController'
}
}
};
var registryRepositories = {
name: 'portainer.registries.registry.repositories',
url: '/repositories',
views: {
'content@': {
templateUrl: 'app/extensions/registry-management/views/repositories/registryRepositories.html',
controller: 'RegistryRepositoriesController'
}
}
};
var registryRepositoryTags = {
name: 'portainer.registries.registry.repository',
url: '/:repository',
views: {
'content@': {
templateUrl: 'app/extensions/registry-management/views/repositories/edit/registryRepository.html',
controller: 'RegistryRepositoryController'
}
}
};
$stateRegistryProvider.register(registryConfiguration);
$stateRegistryProvider.register(registryRepositories);
$stateRegistryProvider.register(registryRepositoryTags);
}]);

View file

@ -0,0 +1,83 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Repository
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('TagsCount')">
Tags count
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="portainer.registries.registry.repository({repository: item.Name})" class="monospaced"
title="{{ item.Name }}">{{ item.Name }}</a>
</td>
<td>{{ item.TagsCount }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-center text-muted">No repository available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View file

@ -0,0 +1,13 @@
angular.module('portainer.extensions.registrymanagement').component('registryRepositoriesDatatable', {
templateUrl: 'app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<'
}
});

View file

@ -0,0 +1,112 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{
$ctrl.titleText }}
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Os/Architecture</th>
<th>
<a ng-click="$ctrl.changeOrderBy('ImageId')">
Image ID
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Size')">
Size
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" />
<label for="select_{{ $index }}"></label>
</span>
{{ item.Name }}
</td>
<td>{{ item.Os }}/{{ item.Architecture }}</td>
<td>{{ item.ImageId | truncate:40 }}</td>
<td>{{ item.Size | humansize }}</td>
<td>
<span ng-if="!item.Modified">
<a class="interactive" ng-click="item.Modified = true; item.NewName = item.Name; $event.stopPropagation();">
<i class="fa fa-tag" aria-hidden="true"></i> Retag
</a>
</span>
<span ng-if="item.Modified">
<input class="input-sm" type="text" ng-model="item.NewName" on-enter-key="$ctrl.retagAction(item)"
auto-focus ng-click="$event.stopPropagation();" />
<a class="interactive" ng-click="item.Modified = false; $event.stopPropagation();"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="$ctrl.retagAction(item); $event.stopPropagation();"><i class="fa fa-check-square"></i></a>
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No tag available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View file

@ -0,0 +1,14 @@
angular.module('portainer.extensions.registrymanagement').component('registriesRepositoryTagsDatatable', {
templateUrl: 'app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<',
retagAction: '<'
}
});

View file

@ -0,0 +1,38 @@
angular.module('portainer.extensions.registrymanagement')
.factory('RegistryV2Helper', [function RegistryV2HelperFactory() {
'use strict';
var helper = {};
function historyRawToParsed(rawHistory) {
var history = [];
for (var i = 0; i < rawHistory.length; i++) {
var item = rawHistory[i];
history.push(angular.fromJson(item.v1Compatibility));
}
return history;
}
helper.manifestsToTag = function (manifests) {
var v1 = manifests.v1;
var v2 = manifests.v2;
var history = historyRawToParsed(v1.history);
var imageId = history[0].id;
var name = v1.tag;
var os = history[0].os;
var arch = v1.architecture;
var size = v2.layers.reduce(function (a, b) {
return {
size: a.size + b.size
};
}).size;
var digest = v2.digest;
var repositoryName = v1.name;
var fsLayers = v1.fsLayers;
return new RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, v2);
};
return helper;
}]);

View file

@ -0,0 +1,4 @@
function RegistryRepositoryViewModel(data) {
this.Name = data.name;
this.TagsCount = data.tags.length;
}

View file

@ -0,0 +1,12 @@
function RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, manifestv2) {
this.Name = name;
this.ImageId = imageId;
this.Os = os;
this.Architecture = arch;
this.Size = size;
this.Digest = digest;
this.RepositoryName = repositoryName;
this.FsLayers = fsLayers;
this.History = history;
this.ManifestV2 = manifestv2;
}

View file

@ -0,0 +1,23 @@
angular.module('portainer.extensions.registrymanagement')
.factory('RegistryCatalog', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryCatalogFactory($resource, API_ENDPOINT_REGISTRIES) {
'use strict';
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:action', {},
{
get: {
method: 'GET',
params: { id: '@id', action: '_catalog' }
},
ping: {
method: 'GET',
params: { id: '@id' }, timeout: 3500
},
pingWithForceNew: {
method: 'GET',
params: { id: '@id' }, timeout: 3500,
headers: { 'X-RegistryManagement-ForceNew': '1' }
}
},
{
stripTrailingSlashes: false
});
}]);

View file

@ -0,0 +1,61 @@
angular.module('portainer.extensions.registrymanagement')
.factory('RegistryManifests', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryManifestsFactory($resource, API_ENDPOINT_REGISTRIES) {
'use strict';
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/manifests/:tag', {}, {
get: {
method: 'GET',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
},
headers: {
'Cache-Control': 'no-cache'
},
transformResponse: function (data, headers) {
var response = angular.fromJson(data);
response.digest = headers('docker-content-digest');
return response;
}
},
getV2: {
method: 'GET',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
},
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
'Cache-Control': 'no-cache'
},
transformResponse: function (data, headers) {
var response = angular.fromJson(data);
response.digest = headers('docker-content-digest');
return response;
}
},
put: {
method: 'PUT',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
},
headers: {
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
},
transformRequest: function (data) {
return angular.toJson(data, 3);
}
},
delete: {
method: 'DELETE',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
}
}
});
}]);

View file

@ -0,0 +1,10 @@
angular.module('portainer.extensions.registrymanagement')
.factory('RegistryTags', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryTagsFactory($resource, API_ENDPOINT_REGISTRIES) {
'use strict';
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/tags/list', {}, {
get: {
method: 'GET',
params: { id: '@id', repository: '@repository' }
}
});
}]);

View file

@ -0,0 +1,118 @@
angular.module('portainer.extensions.registrymanagement')
.factory('RegistryV2Service', ['$q', 'RegistryCatalog', 'RegistryTags', 'RegistryManifests', 'RegistryV2Helper',
function RegistryV2ServiceFactory($q, RegistryCatalog, RegistryTags, RegistryManifests, RegistryV2Helper) {
'use strict';
var service = {};
service.ping = function(id, forceNewConfig) {
if (forceNewConfig) {
return RegistryCatalog.pingWithForceNew({ id: id }).$promise;
}
return RegistryCatalog.ping({ id: id }).$promise;
};
service.repositories = function (id) {
var deferred = $q.defer();
RegistryCatalog.get({
id: id
}).$promise
.then(function success(data) {
var promises = [];
for (var i = 0; i < data.repositories.length; i++) {
var repository = data.repositories[i];
promises.push(RegistryTags.get({
id: id,
repository: repository
}).$promise);
}
return $q.all(promises);
})
.then(function success(data) {
var repositories = data.map(function (item) {
if (!item.tags) {
return;
}
return new RegistryRepositoryViewModel(item);
});
repositories = _.without(repositories, undefined);
deferred.resolve(repositories);
})
.catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve repositories',
err: err
});
});
return deferred.promise;
};
service.tags = function (id, repository) {
var deferred = $q.defer();
RegistryTags.get({
id: id,
repository: repository
}).$promise
.then(function succes(data) {
deferred.resolve(data.tags);
}).catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve tags',
err: err
});
});
return deferred.promise;
};
service.tag = function (id, repository, tag) {
var deferred = $q.defer();
var promises = {
v1: RegistryManifests.get({
id: id,
repository: repository,
tag: tag
}).$promise,
v2: RegistryManifests.getV2({
id: id,
repository: repository,
tag: tag
}).$promise
};
$q.all(promises)
.then(function success(data) {
var tag = RegistryV2Helper.manifestsToTag(data);
deferred.resolve(tag);
}).catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve tag ' + tag,
err: err
});
});
return deferred.promise;
};
service.addTag = function (id, repository, tag, manifest) {
delete manifest.digest;
return RegistryManifests.put({
id: id,
repository: repository,
tag: tag
}, manifest).$promise;
};
service.deleteManifest = function (id, repository, digest) {
return RegistryManifests.delete({
id: id,
repository: repository,
tag: digest
}).$promise;
};
return service;
}
]);

View file

@ -0,0 +1,66 @@
angular.module('portainer.extensions.registrymanagement')
.controller('ConfigureRegistryController', ['$scope', '$state', '$transition$', 'RegistryService', 'RegistryV2Service', 'Notifications',
function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Notifications) {
$scope.state = {
testInProgress: false,
updateInProgress: false,
validConfiguration : false
};
$scope.testConfiguration = testConfiguration;
$scope.updateConfiguration = updateConfiguration;
function testConfiguration() {
$scope.state.testInProgress = true;
RegistryService.configureRegistry($scope.registry.Id, $scope.model)
.then(function success() {
return RegistryV2Service.ping($scope.registry.Id, true);
})
.then(function success() {
Notifications.success('Success', 'Valid management configuration');
$scope.state.validConfiguration = true;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Invalid management configuration');
})
.finally(function final() {
$scope.state.testInProgress = false;
});
}
function updateConfiguration() {
$scope.state.updateInProgress = true;
RegistryService.configureRegistry($scope.registry.Id, $scope.model)
.then(function success() {
Notifications.success('Success', 'Registry management configuration updated');
$state.go('portainer.registries.registry.repositories', { id: $scope.registry.Id }, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update registry management configuration');
})
.finally(function final() {
$scope.state.updateInProgress = false;
});
}
function initView() {
var registryId = $transition$.params().id;
RegistryService.registry(registryId)
.then(function success(data) {
var registry = data;
var model = new RegistryManagementConfigurationDefaultModel(registry);
$scope.registry = registry;
$scope.model = model;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve registry details');
});
}
initView();
}]);

View file

@ -0,0 +1,161 @@
<rd-header>
<rd-header-title title-text="Configure registry"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.registries">Registries</a> &gt; <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> &gt; Management configuration
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
The following configuration will be used to access this <a href="https://docs.docker.com/registry/spec/api/" target="_blank">registry API</a> to provide Portainer management features.
</span>
</div>
<div class="col-sm-12 form-section-title">
Registry details
</div>
<!-- registry-url-input -->
<div class="form-group">
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
Registry URL
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_url" ng-model="registry.URL" disabled>
</div>
</div>
<!-- !registry-url-input -->
<!-- authentication-checkbox -->
<div class="form-group" ng-if="registry.Type === 3">
<div class="col-sm-12">
<label for="registry_auth" class="control-label text-left">
Authentication
<portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to this registry."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="model.Authentication"><i></i>
</label>
</div>
</div>
<!-- !authentication-checkbox -->
<!-- authentication-credentials -->
<div ng-if="model.Authentication">
<!-- credentials-user -->
<div class="form-group">
<label for="credentials_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="credentials_username" ng-model="model.Username">
</div>
</div>
<!-- !credentials-user -->
<!-- credentials-password -->
<div class="form-group">
<label for="credentials_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="credentials_password" ng-model="model.Password" placeholder="*******">
</div>
</div>
<!-- !credentials-password -->
</div>
<!-- !authentication-credentials -->
<!-- tls -->
<div ng-if="registry.Type === 3">
<!-- tls-checkbox -->
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
TLS
<portainer-tooltip position="bottom" message="Enable this option if you need to connect to the registry API with TLS."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="model.TLS"><i></i>
</label>
</div>
</div>
<!-- !tls-checkbox -->
<!-- tls-skip-verify -->
<div class="form-group" ng-if="model.TLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Skip certificate verification
<portainer-tooltip position="bottom" message="Skip the verification of the server TLS certificate. Not recommended on unsecured networks."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="model.TLSSkipVerify"><i></i>
</label>
</div>
</div>
<!-- !tls-skip-verify -->
<div class="col-sm-12 form-section-title" ng-if="model.TLS && !model.TLSSkipVerify">
Required TLS files
</div>
<!-- tls-file-upload -->
<div ng-if="model.TLS && !model.TLSSkipVerify">
<!-- tls-ca-file-cert -->
<div class="form-group">
<label for="tls_cert" class="col-sm-3 col-lg-2 control-label text-left">TLS CA certificate</label>
<div class="col-sm-9 col-lg-10">
<button class="btn btn-sm btn-primary" ngf-select ng-model="model.TLSCACertFile">Select file</button>
<span style="margin-left: 5px;">
{{ model.TLSCACertFile.name }}
<i class="fa fa-check green-icon" ng-if="model.TLSCACertFile && model.TLSCACertFile === registry.ManagementConfiguration.TLSConfig.TLSCACertFile" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!model.TLSCACertFile" aria-hidden="true"></i>
</span>
</div>
</div>
<!-- !tls-ca-file-cert -->
<!-- tls-file-cert -->
<div class="form-group">
<label for="tls_cert" class="col-sm-3 col-lg-2 control-label text-left">TLS certificate</label>
<div class="col-sm-9 col-lg-10">
<button class="btn btn-sm btn-primary" ngf-select ng-model="model.TLSCertFile">Select file</button>
<span style="margin-left: 5px;">
{{ model.TLSCertFile.name }}
<i class="fa fa-check green-icon" ng-if="model.TLSCertFile && model.TLSCertFile === registry.ManagementConfiguration.TLSConfig.TLSCertFile" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!model.TLSCertFile" aria-hidden="true"></i>
</span>
</div>
</div>
<!-- !tls-file-cert -->
<!-- tls-file-key -->
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">TLS key</label>
<div class="col-sm-9 col-lg-10">
<button class="btn btn-sm btn-primary" ngf-select ng-model="model.TLSKeyFile">Select file</button>
<span style="margin-left: 5px;">
{{ model.TLSKeyFile.name }}
<i class="fa fa-check green-icon" ng-if="model.TLSKeyFile && model.TLSKeyFile === registry.ManagementConfiguration.TLSConfig.TLSKeyFile" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!model.TLSKeyFile" aria-hidden="true"></i>
</span>
</div>
</div>
<!-- !tls-file-key -->
</div>
</div>
<!-- !tls -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.testInProgress" ng-click="testConfiguration()" button-spinner="state.testInProgress">
<span ng-hide="state.testInProgress">Test configuration</span>
<span ng-show="state.testInProgress">Test in progress...</span>
</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.updateInProgress || !state.validConfiguration" ng-click="updateConfiguration()" button-spinner="state.updateInProgress">
<span ng-hide="state.updateInProgress">Update configuration</span>
<span ng-show="state.updateInProgress">Updating configuration...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,88 @@
<rd-header>
<rd-header-title title-text="Repository">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.registries.registry.repository" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="portainer.registries">Registries</a> &gt;
<a ui-sref="portainer.registries.registry.repositories({id: registry.Id})">{{ registry.Name }}</a> &gt;
<a ui-sref="portainer.registries.registry.repository()">{{ repository.Name }} </a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-8">
<rd-widget>
<rd-widget-header icon="fa-info" title-text="Repository information">
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Repository</td>
<td>
{{ repository.Name }}
<button class="btn btn-xs btn-danger" ng-click="removeRepository()">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this repository
</button>
</td>
</tr>
<tr>
<td>Tags count</td>
<td>{{ repository.Tags.length }}</td>
</tr>
<tr>
<td>Images count</td>
<td>{{ repository.Images.length }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-sm-4">
<rd-widget>
<rd-widget-header icon="fa-plus" title-text="Add tag">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<label for="tag" class="col-sm-3 col-lg-2 control-label text-left">Tag</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="tag" ng-model="formValues.Tag">
</div>
</div>
<div class="form-group">
<label for="image" class="col-sm-3 col-lg-2 control-label text-left">Image</label>
<ui-select class="col-sm-9 col-lg-10" ng-model="formValues.SelectedImage" id="image">
<ui-select-match placeholder="Select an image" allow-clear="true">
<span>{{ $select.selected }}</span>
</ui-select-match>
<ui-select-choices repeat="image in (repository.Images | filter: $select.search)">
<span>{{ image }}</span>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Tag || !formValues.SelectedImage"
ng-click="addTag()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Add tag</span>
<span ng-show="state.actionInProgress">Adding tag...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<registries-repository-tags-datatable title-text="Tags" title-icon="fa-tags" dataset="tags" table-key="registryRepositoryTags"
order-by="Name" remove-action="removeTags" retag-action="retagAction"></registries-repository-tags-datatable>
</div>
</div>

View file

@ -0,0 +1,150 @@
angular.module('portainer.app')
.controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications',
function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) {
$scope.state = {
actionInProgress: false
};
$scope.formValues = {
Tag: ''
};
$scope.tags = [];
$scope.repository = {
Name: [],
Tags: [],
Images: []
};
$scope.$watch('tags.length', function () {
var images = $scope.tags.map(function (item) {
return item.ImageId;
});
$scope.repository.Images = _.uniq(images);
});
$scope.addTag = function () {
var manifest = $scope.tags.find(function (item) {
return item.ImageId === $scope.formValues.SelectedImage;
}).ManifestV2;
RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest)
.then(function success() {
Notifications.success('Success', 'Tag successfully added');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to add tag');
});
};
$scope.retagAction = function (tag) {
RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest)
.then(function success() {
var promises = [];
var tagsToAdd = $scope.tags.filter(function (item) {
return item.Digest === tag.Digest;
});
tagsToAdd.map(function (item) {
var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name;
promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2));
});
return $q.all(promises);
})
.then(function success() {
Notifications.success('Success', 'Tag successfully modified');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to modify tag');
tag.Modified = false;
tag.NewValue = tag.Value;
});
};
$scope.removeTags = function (selectedItems) {
ModalService.confirmDeletion(
'Are you sure you want to remove the selected tags ?',
function onConfirm(confirmed) {
if (!confirmed) {
return;
}
var promises = [];
var uniqItems = _.uniqBy(selectedItems, 'Digest');
uniqItems.map(function (item) {
promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest));
});
$q.all(promises)
.then(function success() {
var promises = [];
var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name');
tagsToReupload.map(function (item) {
promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2));
});
return $q.all(promises);
})
.then(function success() {
Notifications.success('Success', 'Tags successfully deleted');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to delete tags');
});
});
};
$scope.removeRepository = function () {
ModalService.confirmDeletion(
'This action will only remove the manifests linked to this repository. You need to manually trigger a garbage collector pass on your registry to remove orphan layers and really remove the images content. THIS ACTION CAN NOT BE UNDONE',
function onConfirm(confirmed) {
if (!confirmed) {
return;
}
var promises = [];
var uniqItems = _.uniqBy($scope.tags, 'Digest');
uniqItems.map(function (item) {
promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest));
});
$q.all(promises)
.then(function success() {
Notifications.success('Success', 'Repository sucessfully removed');
$state.go('portainer.registries.registry.repositories', {
id: $scope.registryId
}, {
reload: true
});
}).catch(function error(err) {
Notifications.error('Failure', err, 'Unable to delete repository');
});
}
);
};
function initView() {
var registryId = $scope.registryId = $transition$.params().id;
var repository = $scope.repository.Name = $transition$.params().repository;
$q.all({
registry: RegistryService.registry(registryId),
tags: RegistryV2Service.tags(registryId, repository)
})
.then(function success(data) {
$scope.registry = data.registry;
$scope.repository.Tags = data.tags;
$scope.tags = [];
for (var i = 0; i < data.tags.length; i++) {
var tag = data.tags[i];
RegistryV2Service.tag(registryId, repository, tag)
.then(function success(data) {
$scope.tags.push(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tag information');
});
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve repository information');
});
}
initView();
}
]);

View file

@ -0,0 +1,37 @@
<rd-header>
<rd-header-title title-text="Registry repositories">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.registries.registry.repositories" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="portainer.registries">Registries</a> &gt; <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> &gt; Repositories
</rd-header-content>
</rd-header>
<div class="row">
<information-panel ng-if="state.displayInvalidConfigurationMessage" title-text="Registry management configuration required">
<span class="small text-muted">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Portainer was not able to use this registry management features. You might need to update the configuration used by Portainer to access this registry.
</p>
<p>Note: Portainer registry management features are only supported with registries exposing the <a href="https://docs.docker.com/registry/spec/api/" target="_blank">v2 registry API</a>.</p>
<div style="margin-top: 7px;">
<a ui-sref="portainer.registries.registry.configure({id: registry.Id})">
<i class="fa fa-wrench" aria-hidden="true"></i> Configure this registry
</a>
</div>
</span>
</information-panel>
</div>
<div class="row" ng-if="repositories">
<div class="col-sm-12">
<registry-repositories-datatable
title-text="Repositories" title-icon="fa-book"
dataset="repositories" table-key="registryRepositories"
order-by="Name">
</registry-repositories-datatable>
</div>
</div>

View file

@ -0,0 +1,33 @@
angular.module('portainer.extensions.registrymanagement')
.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications',
function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications) {
$scope.state = {
displayInvalidConfigurationMessage: false
};
function initView() {
var registryId = $transition$.params().id;
RegistryService.registry(registryId)
.then(function success(data) {
$scope.registry = data;
RegistryV2Service.ping(registryId, false)
.then(function success() {
return RegistryV2Service.repositories(registryId);
})
.then(function success(data) {
$scope.repositories = data;
})
.catch(function error() {
$scope.state.displayInvalidConfigurationMessage = true;
});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve registry details');
});
}
initView();
}]);

View file

@ -1,3 +1,4 @@
// TODO: legacy extension management
angular.module('extension.storidge', [])
.config(['$stateRegistryProvider', function ($stateRegistryProvider) {
'use strict';