Merge "Adding generic table extensibility"
This commit is contained in:
commit
5d618bf490
@ -64,11 +64,13 @@
|
||||
var properties = {};
|
||||
this.setProperty = setProperty;
|
||||
this.getName = getName;
|
||||
this.setNames = setNames;
|
||||
this.label = label;
|
||||
this.format = format;
|
||||
this.type = type;
|
||||
this.setLoadFunction = setLoadFunction;
|
||||
this.load = load;
|
||||
this.loadFunction = function def() { return Promise.resolve({data: {}}); };
|
||||
|
||||
// These members support the ability of a type to provide a function
|
||||
// that, given an object in the structure presented by the
|
||||
@ -77,6 +79,31 @@
|
||||
this.setItemNameFunction = setItemNameFunction;
|
||||
this.itemName = itemName;
|
||||
|
||||
// These members support 'global actions' which are actions associated
|
||||
// with a resource type, but don't need an existing resource in order
|
||||
// to be run.
|
||||
this.globalActions = [];
|
||||
extensibleService(this.globalActions, this.globalActions);
|
||||
|
||||
// These members support summary templates which are views that are meant
|
||||
// to exist in a confined area, such as a drawer in a table row.
|
||||
this.summaryTemplateUrl = false;
|
||||
this.setSummaryTemplateUrl = setSummaryTemplateUrl;
|
||||
|
||||
// The list function is meant to provide a standard list of a particular
|
||||
// type, with the data as a result in a promise. For example, Images code
|
||||
// would register a list function that returns a promise that will resolve
|
||||
// to all the Images data in list form.
|
||||
this.listFunction = angular.noop;
|
||||
this.setListFunction = setListFunction;
|
||||
|
||||
// The table columns are an extensible registration of columns of data
|
||||
// that could be displayed in a table/grid format. The specification
|
||||
// for the data elements is defined in the code for hz-dynamic-table.
|
||||
this.tableColumns = [];
|
||||
extensibleService(this.tableColumns, this.tableColumns);
|
||||
this.getTableColumns = getTableColumns;
|
||||
|
||||
// The purpose of these members is to allow details to be retrieved
|
||||
// automatically from such a path, or similarly to create a path
|
||||
// to such a route from any reference. This establishes a two-way
|
||||
@ -84,7 +111,7 @@
|
||||
// The path could be used as part of a details route, for example:
|
||||
//
|
||||
// An identifier of 'abc-defg' would yield '/abc-defg' which
|
||||
// could be used in a details url, such as:
|
||||
// could be used in a details route, such as:
|
||||
// '/details/OS::Glance::Image/abc-defg'
|
||||
this.pathParser = defaultPathParser;
|
||||
this.setPathParser = setPathParser;
|
||||
@ -168,6 +195,62 @@
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ngdoc function
|
||||
* @name setListFunction
|
||||
* @description
|
||||
* Sets a function that returns a promise, that resolves with a list
|
||||
* of all the items of a given type.
|
||||
* @example
|
||||
```
|
||||
myResourceType.setListFunction(loadData);
|
||||
|
||||
function loadData() {
|
||||
return myResourceTypeApi.get();
|
||||
}
|
||||
|
||||
elsewhere:
|
||||
myResourceType.list().then(doSomethingWithResult);
|
||||
```
|
||||
*/
|
||||
function setListFunction(func) {
|
||||
this.listFunction = func;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ngdoc function
|
||||
* @name getTableColumns
|
||||
* @description
|
||||
* This is a convenience function that provides the table column
|
||||
* information back, but places a 'title' member on if not already
|
||||
* present, using the label provided for the resourceType's member of
|
||||
* the same name.
|
||||
* This function is a way of making use of the pre-existing registration
|
||||
* of a human-readable for the given property, so we don't have to specify
|
||||
* it again in the column registration.
|
||||
* @example
|
||||
```
|
||||
resourceType.setProperty('owner', {
|
||||
label: gettext('Owner')
|
||||
});
|
||||
resourceType.tableColumns.append({'id': 'owner'}); // no 'title'
|
||||
|
||||
var columns = resourceType.getTableColumns();
|
||||
// columns[0] will contain {'id': 'owner', 'title': 'Owner'}
|
||||
|
||||
```
|
||||
*/
|
||||
function getTableColumns() {
|
||||
return this.tableColumns.map(mapTableInfo);
|
||||
|
||||
function mapTableInfo(x) {
|
||||
var tableInfo = x;
|
||||
tableInfo.title = x.title || label(x.id);
|
||||
return tableInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @ngdoc function
|
||||
* @name setPathParser
|
||||
@ -271,6 +354,7 @@
|
||||
+ '/listener/' + descriptor.id
|
||||
}
|
||||
|
||||
var path = resourceType.detailsPath({id: 12, balancerId: 'abasefasdf');
|
||||
```
|
||||
*/
|
||||
function setPathGenerator(func) {
|
||||
@ -278,6 +362,13 @@
|
||||
return this;
|
||||
}
|
||||
|
||||
// The reason for this function as opposed to just setting the value
|
||||
// is solely to make it easy to chain commands.
|
||||
function setSummaryTemplateUrl(url) {
|
||||
this.summaryTemplateUrl = url;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Functions relating item names, described above.
|
||||
function defaultItemNameFunction(item) {
|
||||
return item.name;
|
||||
@ -323,6 +414,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @ngdoc function
|
||||
* @name setNames
|
||||
* @description
|
||||
* Takes in the singular/plural names used for display.
|
||||
* @example
|
||||
```
|
||||
var resourceType = getResourceType('thing')
|
||||
.setNames(gettext('Thing'), gettext('Things'));
|
||||
});
|
||||
|
||||
```
|
||||
*/
|
||||
function setNames(singular, plural) {
|
||||
this.names = [singular, plural];
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ngdoc function
|
||||
* @name label
|
||||
@ -397,14 +506,42 @@
|
||||
}
|
||||
|
||||
var resourceTypes = {};
|
||||
// The slugs are only used to align Django routes with heat
|
||||
// type names. In a context without Django routing this is
|
||||
// not needed.
|
||||
var slugs = {};
|
||||
var defaultSummaryTemplateUrl = false;
|
||||
var defaultDetailsTemplateUrl = false;
|
||||
var registry = {
|
||||
getResourceType: getResourceType,
|
||||
initActions: initActions,
|
||||
getGlobalActions: getGlobalActions,
|
||||
setDefaultSummaryTemplateUrl: setDefaultSummaryTemplateUrl,
|
||||
getDefaultSummaryTemplateUrl: getDefaultSummaryTemplateUrl,
|
||||
setDefaultDetailsTemplateUrl: setDefaultDetailsTemplateUrl,
|
||||
getDefaultDetailsTemplateUrl: getDefaultDetailsTemplateUrl,
|
||||
getResourceType: getResourceType,
|
||||
initActions: initActions
|
||||
setSlug: setSlug,
|
||||
getTypeNameBySlug: getTypeNameBySlug
|
||||
};
|
||||
|
||||
function getTypeNameBySlug(slug) {
|
||||
return slugs[slug];
|
||||
}
|
||||
|
||||
function setSlug(slug, typeName) {
|
||||
slugs[slug] = typeName;
|
||||
return this;
|
||||
}
|
||||
|
||||
function getDefaultSummaryTemplateUrl() {
|
||||
return defaultSummaryTemplateUrl;
|
||||
}
|
||||
|
||||
function setDefaultSummaryTemplateUrl(url) {
|
||||
defaultSummaryTemplateUrl = url;
|
||||
return this;
|
||||
}
|
||||
|
||||
function getDefaultDetailsTemplateUrl() {
|
||||
return defaultDetailsTemplateUrl;
|
||||
}
|
||||
@ -424,22 +561,36 @@
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
* @ngdoc function
|
||||
* @name getGlobalActions
|
||||
* @description
|
||||
* This is a convenience function for retrieving all the global actions
|
||||
* across all the resource types. This is valuable when a page wants to
|
||||
* display all actions that can be taken without having selected a resource
|
||||
* type, or otherwise needing to access all global actions.
|
||||
*/
|
||||
function getGlobalActions() {
|
||||
var actions = [];
|
||||
angular.forEach(resourceTypes, appendActions);
|
||||
return actions;
|
||||
|
||||
function appendActions(type) {
|
||||
actions = actions.concat(type.globalActions);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* @ngdoc function
|
||||
* @name getResourceType
|
||||
* @description
|
||||
* Retrieves all information about a resource type. If the resource
|
||||
* type doesn't exist in the registry, this creates a new entry.
|
||||
* If a configuration is supplied, the resource type is extended to
|
||||
* use the configuration's properties.
|
||||
*/
|
||||
function getResourceType(type, config) {
|
||||
function getResourceType(type) {
|
||||
if (!resourceTypes.hasOwnProperty(type)) {
|
||||
resourceTypes[type] = new ResourceType(type);
|
||||
}
|
||||
if (angular.isDefined(config)) {
|
||||
angular.extend(resourceTypes[type], config);
|
||||
}
|
||||
return resourceTypes[type];
|
||||
}
|
||||
|
||||
@ -454,6 +605,7 @@
|
||||
function initActions(type, scope) {
|
||||
angular.forEach(resourceTypes[type].itemActions, setActionScope);
|
||||
angular.forEach(resourceTypes[type].batchActions, setActionScope);
|
||||
angular.forEach(resourceTypes[type].globalActions, setActionScope);
|
||||
|
||||
function setActionScope(action) {
|
||||
if (action.service.initScope) {
|
||||
|
@ -61,25 +61,19 @@
|
||||
});
|
||||
|
||||
it('returns a new resource type object', function() {
|
||||
var value = service.getResourceType('something', {here: "I am"});
|
||||
var value = service.getResourceType('something');
|
||||
expect(value).toBeDefined();
|
||||
});
|
||||
|
||||
it('takes the given properties', function() {
|
||||
var value = service.getResourceType('something', {here: "I am"});
|
||||
expect(value.here).toEqual('I am');
|
||||
});
|
||||
|
||||
it('has an setProperty function', function() {
|
||||
var value = service.getResourceType('something', {here: "I am"});
|
||||
var value = service.getResourceType('something');
|
||||
expect(value.setProperty).toBeDefined();
|
||||
});
|
||||
|
||||
it('can be called multiple times, overlaying values', function() {
|
||||
var value = service.getResourceType('something', {here: "I am"});
|
||||
service.getResourceType('something', {another: "Thing"});
|
||||
expect(value.here).toBe('I am');
|
||||
expect(value.another).toBe('Thing');
|
||||
it('can be called multiple times', function() {
|
||||
var value = service.getResourceType('something');
|
||||
var value2 = service.getResourceType('something');
|
||||
expect(value).toBe(value2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -88,6 +82,11 @@
|
||||
expect(service.getDefaultDetailsTemplateUrl()).toBe('/my/path.html');
|
||||
});
|
||||
|
||||
it('get/setDefaultSummaryTemplateUrl sets/retrieves a URL', function() {
|
||||
service.setDefaultSummaryTemplateUrl('/my/path.html');
|
||||
expect(service.getDefaultSummaryTemplateUrl()).toBe('/my/path.html');
|
||||
});
|
||||
|
||||
describe('label', function() {
|
||||
var label;
|
||||
beforeEach(function() {
|
||||
@ -161,35 +160,80 @@
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and retrieves slugs", function() {
|
||||
service.setSlug('image', 'OS::Glance::Image');
|
||||
expect(service.getTypeNameBySlug('image')).toBe('OS::Glance::Image');
|
||||
});
|
||||
|
||||
describe('getName', function() {
|
||||
it('returns nothing if names not provided', function() {
|
||||
var type = service.getResourceType('something', {});
|
||||
var type = service.getResourceType('something');
|
||||
expect(type.getName(2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns plural if count not provided', function() {
|
||||
var type = service.getResourceType('something',
|
||||
{names: ['Name', 'Names']});
|
||||
var type = service.getResourceType('something')
|
||||
.setNames('Name', 'Names');
|
||||
expect(type.getName()).toBe('Names');
|
||||
});
|
||||
|
||||
it('returns singular if given one', function() {
|
||||
var type = service.getResourceType('something', {names: ["Image", "Images"]});
|
||||
var type = service.getResourceType('something')
|
||||
.setNames("Image", "Images");
|
||||
expect(type.getName(1)).toBe('Image');
|
||||
});
|
||||
|
||||
it('returns plural if given two', function() {
|
||||
var type = service.getResourceType('something', {names: ["Image", "Images"]});
|
||||
var type = service.getResourceType('something')
|
||||
.setNames("Image", "Images");
|
||||
expect(type.getName(2)).toBe('Images');
|
||||
});
|
||||
});
|
||||
|
||||
it('manages the tableColumns', function() {
|
||||
var type = service.getResourceType('something');
|
||||
type.tableColumns.push({id: "im-an-id"});
|
||||
type.tableColumns.push({title: "im-a-title"});
|
||||
expect(type.getTableColumns()).toEqual([{id: "im-an-id", title: "im-an-id"},
|
||||
{title: "im-a-title"}]);
|
||||
});
|
||||
|
||||
it('manages the globalActions', function() {
|
||||
var typeA = service.getResourceType('a');
|
||||
var typeB = service.getResourceType('b');
|
||||
typeA.globalActions.push({id: "action-a"});
|
||||
typeB.globalActions.push({id: "action-b"});
|
||||
expect(service.getGlobalActions()).toEqual([{id: "action-a"}, {id: "action-b"}]);
|
||||
});
|
||||
|
||||
describe('functions the resourceType object', function() {
|
||||
var type;
|
||||
beforeEach(function() {
|
||||
type = service.getResourceType('something');
|
||||
});
|
||||
|
||||
it("sets a default path generator", function() {
|
||||
expect(type.pathGenerator('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it("default load function returns a promise", function() {
|
||||
expect(type.loadFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows setting a list function", function() {
|
||||
function list() {
|
||||
return 'this would be a promise';
|
||||
}
|
||||
type.setListFunction(list);
|
||||
expect(type.listFunction()).toBe('this would be a promise');
|
||||
});
|
||||
|
||||
it("allows setting of a summary template URL", function() {
|
||||
type.setSummaryTemplateUrl('/my/path.html');
|
||||
expect(type.summaryTemplateUrl).toBe('/my/path.html');
|
||||
expect(type.setSummaryTemplateUrl('/what')).toBe(type);
|
||||
});
|
||||
|
||||
it('itemName defaults to returning the name of an item', function() {
|
||||
var item = {name: 'MegaMan'};
|
||||
expect(type.itemName(item)).toBe('MegaMan');
|
||||
|
@ -37,6 +37,7 @@
|
||||
*/
|
||||
var horizonBuiltInModules = [
|
||||
'horizon.app.core',
|
||||
'horizon.app.resources',
|
||||
'horizon.app.tech-debt',
|
||||
'horizon.auth',
|
||||
'horizon.framework'
|
||||
|
@ -43,11 +43,7 @@
|
||||
], config)
|
||||
// NOTE: this will move into the correct module as that resource type
|
||||
// becomes available. For now there is no volumes module.
|
||||
.constant('horizon.app.core.volumes.resourceType', VOLUME_RESOURCE_TYPE)
|
||||
.run([
|
||||
'horizon.framework.conf.resource-type-registry.service',
|
||||
performRegistrations
|
||||
]);
|
||||
.constant('horizon.app.core.volumes.resourceType', VOLUME_RESOURCE_TYPE);
|
||||
|
||||
config.$inject = ['$provide', '$windowProvider', '$routeProvider'];
|
||||
|
||||
@ -61,71 +57,4 @@
|
||||
});
|
||||
}
|
||||
|
||||
function performRegistrations(registry) {
|
||||
// The items in this long list of registrations should ideally placed into
|
||||
// respective module declarations. However, until they are more fully
|
||||
// fleshed out there's no reason to pollute the directory/file structure.
|
||||
// As a model, the Images registration happens in the images module.
|
||||
registry.getResourceType('OS::Glance::Metadef', {
|
||||
names: [gettext('Metadata Definition'), gettext('Metadata Definitions')]
|
||||
});
|
||||
registry.getResourceType('OS::Nova::Server', {
|
||||
names: [gettext('Instance'), gettext('Instances')]
|
||||
});
|
||||
registry.getResourceType('OS::Nova::Flavor', {
|
||||
names: [gettext('Flavor'), gettext('Flavors')]
|
||||
});
|
||||
registry.getResourceType('OS::Nova::Hypervisor', {
|
||||
names: [gettext('Hypervisor'), gettext('Hypervisors')]
|
||||
});
|
||||
registry.getResourceType('OS::Nova::Keypair', {
|
||||
names: [gettext('Key Pair'), gettext('Key Pairs')]
|
||||
});
|
||||
registry.getResourceType('OS::Designate::Zone', {
|
||||
names: [gettext('DNS Domain'), gettext('DNS Domains')]
|
||||
});
|
||||
registry.getResourceType('OS::Designate::RecordSet', {
|
||||
names: [gettext('DNS Record'), gettext('DNS Records')]
|
||||
});
|
||||
registry.getResourceType('OS::Cinder::Backup', {
|
||||
names: [gettext('Volume Backup'), gettext('Volume Backups')]
|
||||
});
|
||||
registry.getResourceType('OS::Cinder::Snapshot', {
|
||||
names: [gettext('Volume Snapshot'), gettext('Volume Snapshots')]
|
||||
});
|
||||
registry.getResourceType(VOLUME_RESOURCE_TYPE, {
|
||||
names: [gettext('Volume'), gettext('Volumes')]
|
||||
});
|
||||
registry.getResourceType('OS::Swift::Account', {
|
||||
names: [gettext('Object Account'), gettext('Object Accounts')]
|
||||
});
|
||||
registry.getResourceType('OS::Swift::Container', {
|
||||
names: [gettext('Object Container'), gettext('Object Containers')]
|
||||
});
|
||||
registry.getResourceType('OS::Swift::Object', {
|
||||
names: [gettext('Object'), gettext('Objects')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::HealthMonitor', {
|
||||
names: [gettext('Network Health Monitor'), gettext('Network Health Monitors')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::Net', {
|
||||
names: [gettext('Network'), gettext('Networks')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::Pool', {
|
||||
names: [gettext('Load Balancer Pool'), gettext('Load Balancer Pools')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::PoolMember', {
|
||||
names: [gettext('Load Balancer Pool Member'), gettext('Load Balancer Pool Members')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::Port', {
|
||||
names: [gettext('Network Port'), gettext('Network Ports')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::Router', {
|
||||
names: [gettext('Network Router'), gettext('Network Routers')]
|
||||
});
|
||||
registry.getResourceType('OS::Neutron::Subnet', {
|
||||
names: [gettext('Network Subnet'), gettext('Network Subnets')]
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
84
openstack_dashboard/static/app/resources/resources.module.js
Normal file
84
openstack_dashboard/static/app/resources/resources.module.js
Normal file
@ -0,0 +1,84 @@
|
||||
/*
|
||||
* (c) Copyright 2016 Hewlett-Packard Development Company, L.P.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @ngdoc overview
|
||||
* @name horizon.app.resources
|
||||
* @description
|
||||
*
|
||||
* # horizon.app.resources
|
||||
*
|
||||
* This module hosts registered resource types. This module file may
|
||||
* contain individual registrations, or may have sub-modules that
|
||||
* more fully contain registrations.
|
||||
*/
|
||||
angular
|
||||
.module('horizon.app.resources', [])
|
||||
.run(performRegistrations);
|
||||
|
||||
performRegistrations.$inject = [
|
||||
'horizon.framework.conf.resource-type-registry.service'
|
||||
];
|
||||
|
||||
function performRegistrations(registry) {
|
||||
// The items in this long list of registrations should ideally placed into
|
||||
// respective module declarations. However, until they are more fully
|
||||
// fleshed out there's no reason to pollute the directory/file structure.
|
||||
// As a model, the Images registration happens in the images module.
|
||||
registry.getResourceType('OS::Glance::Metadef')
|
||||
.setNames(gettext('Metadata Definition'), gettext('Metadata Definitions'));
|
||||
registry.getResourceType('OS::Nova::Server')
|
||||
.setNames(gettext('Instance'), gettext('Instances'));
|
||||
registry.getResourceType('OS::Nova::Flavor')
|
||||
.setNames(gettext('Flavor'), gettext('Flavors'));
|
||||
registry.getResourceType('OS::Nova::Hypervisor')
|
||||
.setNames(gettext('Hypervisor'), gettext('Hypervisors'));
|
||||
registry.getResourceType('OS::Nova::Keypair')
|
||||
.setNames(gettext('Key Pair'), gettext('Key Pairs'));
|
||||
registry.getResourceType('OS::Designate::Zone')
|
||||
.setNames(gettext('DNS Domain'), gettext('DNS Domains'));
|
||||
registry.getResourceType('OS::Designate::RecordSet')
|
||||
.setNames(gettext('DNS Record'), gettext('DNS Records'));
|
||||
registry.getResourceType('OS::Cinder::Backup')
|
||||
.setNames(gettext('Volume Backup'), gettext('Volume Backups'));
|
||||
registry.getResourceType('OS::Cinder::Snapshot')
|
||||
.setNames(gettext('Volume Snapshot'), gettext('Volume Snapshots'));
|
||||
registry.getResourceType('OS::Cinder::Volume')
|
||||
.setNames(gettext('Volume'), gettext('Volumes'));
|
||||
registry.getResourceType('OS::Swift::Account')
|
||||
.setNames(gettext('Object Account'), gettext('Object Accounts'));
|
||||
registry.getResourceType('OS::Swift::Container')
|
||||
.setNames(gettext('Object Container'), gettext('Object Containers'));
|
||||
registry.getResourceType('OS::Swift::Object')
|
||||
.setNames(gettext('Object'), gettext('Objects'));
|
||||
registry.getResourceType('OS::Neutron::HealthMonitor')
|
||||
.setNames(gettext('Network Health Monitor'), gettext('Network Health Monitors'));
|
||||
registry.getResourceType('OS::Neutron::Net')
|
||||
.setNames(gettext('Network'), gettext('Networks'));
|
||||
registry.getResourceType('OS::Neutron::Pool')
|
||||
.setNames(gettext('Load Balancer Pool'), gettext('Load Balancer Pools'));
|
||||
registry.getResourceType('OS::Neutron::PoolMember')
|
||||
.setNames(gettext('Load Balancer Pool Member'), gettext('Load Balancer Pool Members'));
|
||||
registry.getResourceType('OS::Neutron::Port')
|
||||
.setNames(gettext('Network Port'), gettext('Network Ports'));
|
||||
registry.getResourceType('OS::Neutron::Router')
|
||||
.setNames(gettext('Network Router'), gettext('Network Routers'));
|
||||
registry.getResourceType('OS::Neutron::Subnet')
|
||||
.setNames(gettext('Network Subnet'), gettext('Network Subnets'));
|
||||
}
|
||||
})();
|
Loading…
Reference in New Issue
Block a user