mirror of
https://github.com/ansible/awx.git
synced 2026-03-21 19:07:39 -02:30
Merge branch 'master' into fix-license_2
This commit is contained in:
8
Makefile
8
Makefile
@@ -413,11 +413,11 @@ rpm-build/$(RPM_NVR).noarch.rpm: rpm-build/$(RPM_NVR).src.rpm
|
|||||||
mock-rpm: rpmtar rpm-build/$(RPM_NVR).noarch.rpm
|
mock-rpm: rpmtar rpm-build/$(RPM_NVR).noarch.rpm
|
||||||
|
|
||||||
ifeq ($(OFFICIAL),yes)
|
ifeq ($(OFFICIAL),yes)
|
||||||
rpm-build/$(GPG_FILE): rpm-build
|
rpm-build/$(GPG_FILE): rpm-build
|
||||||
gpg --export -a "${GPG_KEY}" > "$@"
|
gpg --export -a "${GPG_KEY}" > "$@"
|
||||||
|
|
||||||
rpm-sign: rpm-build/$(GPG_FILE) rpmtar rpm-build/$(RPM_NVR).noarch.rpm
|
rpm-sign: rpm-build/$(GPG_FILE) rpmtar rpm-build/$(RPM_NVR).noarch.rpm
|
||||||
rpm --define "_signature gpg" --define "_gpg_name $(GPG_KEY)" --addsign rpm-build/$(RPM_NVR).noarch.rpm
|
rpm --define "_signature gpg" --define "_gpg_name $(GPG_KEY)" --addsign rpm-build/$(RPM_NVR).noarch.rpm
|
||||||
endif
|
endif
|
||||||
|
|
||||||
deb-build/$(SDIST_TAR_NAME):
|
deb-build/$(SDIST_TAR_NAME):
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ class ApiV1ConfigView(APIView):
|
|||||||
mongodb_control.delay('stop')
|
mongodb_control.delay('stop')
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return Response()
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
class DashboardView(APIView):
|
class DashboardView(APIView):
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ class FactVersion(Document):
|
|||||||
meta = {
|
meta = {
|
||||||
'indexes': [
|
'indexes': [
|
||||||
'-timestamp',
|
'-timestamp',
|
||||||
'module'
|
'module',
|
||||||
|
'host',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,53 +31,48 @@ class CleanupFacts(object):
|
|||||||
# pivot -= granularity
|
# pivot -= granularity
|
||||||
# group by host
|
# group by host
|
||||||
def cleanup(self, older_than_abs, granularity, module=None):
|
def cleanup(self, older_than_abs, granularity, module=None):
|
||||||
flag_delete_all = False
|
|
||||||
fact_oldest = FactVersion.objects.all().order_by('timestamp').first()
|
fact_oldest = FactVersion.objects.all().order_by('timestamp').first()
|
||||||
if not fact_oldest:
|
if not fact_oldest:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
kv = {
|
||||||
|
'timestamp__lte': older_than_abs
|
||||||
|
}
|
||||||
|
if module:
|
||||||
|
kv['module'] = module
|
||||||
|
|
||||||
# Special case, granularity=0x where x is d, w, or y
|
# Special case, granularity=0x where x is d, w, or y
|
||||||
# The intent is to delete all facts < older_than_abs
|
# The intent is to delete all facts < older_than_abs
|
||||||
if granularity == relativedelta():
|
if granularity == relativedelta():
|
||||||
flag_delete_all = True
|
return FactVersion.objects.filter(**kv).order_by('-timestamp').delete()
|
||||||
|
|
||||||
total = 0
|
total = 0
|
||||||
|
|
||||||
date_pivot = older_than_abs
|
date_pivot = older_than_abs
|
||||||
while date_pivot > fact_oldest.timestamp:
|
while date_pivot > fact_oldest.timestamp:
|
||||||
date_pivot_next = date_pivot - granularity
|
date_pivot_next = date_pivot - granularity
|
||||||
|
|
||||||
|
# For the current time window.
|
||||||
|
# Delete all facts expect the fact that matches the largest timestamp.
|
||||||
kv = {
|
kv = {
|
||||||
'timestamp__lte': date_pivot
|
'timestamp__lte': date_pivot
|
||||||
}
|
}
|
||||||
if not flag_delete_all:
|
|
||||||
kv['timestamp__gt'] = date_pivot_next
|
|
||||||
if module:
|
if module:
|
||||||
kv['module'] = module
|
kv['module'] = module
|
||||||
|
|
||||||
version_objs = FactVersion.objects.filter(**kv).order_by('-timestamp')
|
|
||||||
|
|
||||||
if flag_delete_all:
|
fact_version_objs = FactVersion.objects.filter(**kv).order_by('-timestamp').limit(1)
|
||||||
total = version_objs.delete()
|
if fact_version_objs:
|
||||||
break
|
fact_version_obj = fact_version_objs[0]
|
||||||
|
kv = {
|
||||||
# Transform array -> {host_id} = [<fact_version>, <fact_version>, ...]
|
'timestamp__lt': fact_version_obj.timestamp,
|
||||||
# TODO: If this set gets large then we can use mongo to transform the data set for us.
|
'timestamp__gt': date_pivot_next
|
||||||
host_ids = {}
|
}
|
||||||
for obj in version_objs:
|
if module:
|
||||||
k = obj.host.id
|
kv['module'] = module
|
||||||
if k not in host_ids:
|
count = FactVersion.objects.filter(**kv).delete()
|
||||||
host_ids[k] = []
|
# FIXME: These two deletes should be a transaction
|
||||||
host_ids[k].append(obj)
|
count = Fact.objects.filter(**kv).delete()
|
||||||
|
|
||||||
for k in host_ids:
|
|
||||||
ids = [fact.id for fact in host_ids[k]]
|
|
||||||
fact_ids = [fact.fact.id for fact in host_ids[k]]
|
|
||||||
# Remove 1 entry
|
|
||||||
ids.pop()
|
|
||||||
fact_ids.pop()
|
|
||||||
# delete the rest
|
|
||||||
count = FactVersion.objects.filter(id__in=ids).delete()
|
|
||||||
# FIXME: if this crashes here then we are inconsistent
|
|
||||||
count = Fact.objects.filter(id__in=fact_ids).delete()
|
|
||||||
total += count
|
total += count
|
||||||
|
|
||||||
date_pivot = date_pivot_next
|
date_pivot = date_pivot_next
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ __all__ = ['CommandTest','CleanupFactsUnitTest', 'CleanupFactsCommandFunctionalT
|
|||||||
class CleanupFactsCommandFunctionalTest(BaseCommandMixin, BaseTest, MongoDBRequired):
|
class CleanupFactsCommandFunctionalTest(BaseCommandMixin, BaseTest, MongoDBRequired):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(CleanupFactsCommandFunctionalTest, self).setUp()
|
super(CleanupFactsCommandFunctionalTest, self).setUp()
|
||||||
|
self.create_test_license_file()
|
||||||
self.builder = FactScanBuilder()
|
self.builder = FactScanBuilder()
|
||||||
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
|
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ class CleanupFactsCommandFunctionalTest(BaseCommandMixin, BaseTest, MongoDBRequi
|
|||||||
self.assertEqual(stdout, 'Deleted 25 facts.\n')
|
self.assertEqual(stdout, 'Deleted 25 facts.\n')
|
||||||
|
|
||||||
class CommandTest(BaseTest):
|
class CommandTest(BaseTest):
|
||||||
|
def setUp(self):
|
||||||
|
super(CommandTest, self).setUp()
|
||||||
|
self.create_test_license_file()
|
||||||
|
|
||||||
@mock.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
|
@mock.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
|
||||||
def test_parameters_ok(self, run):
|
def test_parameters_ok(self, run):
|
||||||
|
|
||||||
@@ -149,11 +154,11 @@ class CleanupFactsUnitTest(BaseCommandMixin, BaseTest, MongoDBRequired):
|
|||||||
|
|
||||||
self.builder = FactScanBuilder()
|
self.builder = FactScanBuilder()
|
||||||
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
|
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
|
||||||
|
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
|
||||||
self.builder.build(scan_count=20, host_count=10)
|
self.builder.build(scan_count=20, host_count=10)
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Create 10 hosts with 20 facts each. A single fact a year for 20 years.
|
Create 10 hosts with 40 facts each. After cleanup, there should be 20 facts for each host.
|
||||||
After cleanup, there should be 10 facts for each host.
|
|
||||||
Then ensure the correct facts are deleted.
|
Then ensure the correct facts are deleted.
|
||||||
'''
|
'''
|
||||||
def test_cleanup_logic(self):
|
def test_cleanup_logic(self):
|
||||||
@@ -162,17 +167,18 @@ class CleanupFactsUnitTest(BaseCommandMixin, BaseTest, MongoDBRequired):
|
|||||||
granularity = relativedelta(years=2)
|
granularity = relativedelta(years=2)
|
||||||
|
|
||||||
deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity)
|
deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity)
|
||||||
self.assertEqual(deleted_count, (self.builder.get_scan_count() * self.builder.get_host_count()) / 2)
|
self.assertEqual(deleted_count, 2 * (self.builder.get_scan_count() * self.builder.get_host_count()) / 2)
|
||||||
|
|
||||||
# Check the number of facts per host
|
# Check the number of facts per host
|
||||||
for host in self.builder.get_hosts():
|
for host in self.builder.get_hosts():
|
||||||
count = FactVersion.objects.filter(host=host).count()
|
count = FactVersion.objects.filter(host=host).count()
|
||||||
self.assertEqual(count, self.builder.get_scan_count() / 2, "should have half the number of FactVersion per host for host %s")
|
scan_count = (2 * self.builder.get_scan_count()) / 2
|
||||||
|
self.assertEqual(count, scan_count)
|
||||||
|
|
||||||
count = Fact.objects.filter(host=host).count()
|
count = Fact.objects.filter(host=host).count()
|
||||||
self.assertEqual(count, self.builder.get_scan_count() / 2, "should have half the number of Fact per host")
|
self.assertEqual(count, scan_count)
|
||||||
|
|
||||||
# Ensure that only 1 fact exists per granularity time
|
# Ensure that only 2 facts (ansible and packages) exists per granularity time
|
||||||
date_pivot = self.builder.get_timestamp(0)
|
date_pivot = self.builder.get_timestamp(0)
|
||||||
for host in self.builder.get_hosts():
|
for host in self.builder.get_hosts():
|
||||||
while date_pivot > fact_oldest.timestamp:
|
while date_pivot > fact_oldest.timestamp:
|
||||||
@@ -183,11 +189,50 @@ class CleanupFactsUnitTest(BaseCommandMixin, BaseTest, MongoDBRequired):
|
|||||||
'host': host,
|
'host': host,
|
||||||
}
|
}
|
||||||
count = FactVersion.objects.filter(**kv).count()
|
count = FactVersion.objects.filter(**kv).count()
|
||||||
self.assertEqual(count, 1, "should only be 1 FactVersion per the 2 year granularity")
|
self.assertEqual(count, 2, "should only be 2 FactVersion per the 2 year granularity")
|
||||||
count = Fact.objects.filter(**kv).count()
|
count = Fact.objects.filter(**kv).count()
|
||||||
self.assertEqual(count, 1, "should only be 1 Fact per the 2 year granularity")
|
self.assertEqual(count, 2, "should only be 2 Fact per the 2 year granularity")
|
||||||
|
date_pivot = date_pivot_next
|
||||||
|
|
||||||
|
'''
|
||||||
|
Create 10 hosts with 40 facts each. After cleanup, there should be 30 facts for each host.
|
||||||
|
Then ensure the correct facts are deleted.
|
||||||
|
'''
|
||||||
|
def test_cleanup_module(self):
|
||||||
|
cleanup_facts = CleanupFacts()
|
||||||
|
fact_oldest = FactVersion.objects.all().order_by('timestamp').first()
|
||||||
|
granularity = relativedelta(years=2)
|
||||||
|
|
||||||
|
deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity, module='ansible')
|
||||||
|
self.assertEqual(deleted_count, (self.builder.get_scan_count() * self.builder.get_host_count()) / 2)
|
||||||
|
|
||||||
|
# Check the number of facts per host
|
||||||
|
for host in self.builder.get_hosts():
|
||||||
|
count = FactVersion.objects.filter(host=host).count()
|
||||||
|
self.assertEqual(count, 30)
|
||||||
|
|
||||||
|
count = Fact.objects.filter(host=host).count()
|
||||||
|
self.assertEqual(count, 30)
|
||||||
|
|
||||||
|
# Ensure that only 1 ansible fact exists per granularity time
|
||||||
|
date_pivot = self.builder.get_timestamp(0)
|
||||||
|
for host in self.builder.get_hosts():
|
||||||
|
while date_pivot > fact_oldest.timestamp:
|
||||||
|
date_pivot_next = date_pivot - granularity
|
||||||
|
kv = {
|
||||||
|
'timestamp__lte': date_pivot,
|
||||||
|
'timestamp__gt': date_pivot_next,
|
||||||
|
'host': host,
|
||||||
|
'module': 'ansible',
|
||||||
|
}
|
||||||
|
count = FactVersion.objects.filter(**kv).count()
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
count = Fact.objects.filter(**kv).count()
|
||||||
|
self.assertEqual(count, 1)
|
||||||
date_pivot = date_pivot_next
|
date_pivot = date_pivot_next
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -440,6 +440,10 @@ var tower = angular.module('Tower', [
|
|||||||
}
|
}
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
when('/inventories/:inventory_id/job_templates/', {
|
||||||
|
redirectTo: '/inventories/:inventory_id'
|
||||||
|
}).
|
||||||
|
|
||||||
when('/inventories/:inventory_id/job_templates/:template_id', {
|
when('/inventories/:inventory_id/job_templates/:template_id', {
|
||||||
name: 'inventoryJobTemplateEdit',
|
name: 'inventoryJobTemplateEdit',
|
||||||
templateUrl: urlPrefix + 'partials/job_templates.html',
|
templateUrl: urlPrefix + 'partials/job_templates.html',
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export default
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
Alert('License Accepted', 'The Ansible Tower license was updated. To view or update license information in the future choose View License from the Account menu.','alert-info');
|
Alert('License Accepted', 'The Ansible Tower license was updated. To view or update license information in the future choose View License from the Account menu.','alert-info');
|
||||||
|
$rootScope.features = undefined;
|
||||||
$location.path('/home');
|
$location.path('/home');
|
||||||
})
|
})
|
||||||
.error(function (data, status) {
|
.error(function (data, status) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
@media screen and (min-width: (@menu-breakpoint + 1px)) {
|
@media screen and (min-width: (@menu-breakpoint + 1px)) {
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
padding-right: 23px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-menuContainer {
|
&-menuContainer {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default ['$route', '$rootScope', function($route, $rootScope) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
scope.$on('$routeChangeSuccess', function(e, nextRoute) {
|
scope.$on('$routeChangeSuccess', function(e, nextRoute) {
|
||||||
if (nextRoute.$$route.name === routeName) {
|
if (nextRoute.$$route && nextRoute.$$route.name === routeName) {
|
||||||
element.addClass('MenuItem--active');
|
element.addClass('MenuItem--active');
|
||||||
} else {
|
} else {
|
||||||
element.removeClass('MenuItem--active');
|
element.removeClass('MenuItem--active');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default ['$scope', '$filter',
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sparkData =
|
var sparkData =
|
||||||
recentJobs.map(function(job) {
|
_.sortBy(recentJobs.map(function(job) {
|
||||||
|
|
||||||
var data = {};
|
var data = {};
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@ export default ['$scope', '$filter',
|
|||||||
|
|
||||||
data.jobId = job.id;
|
data.jobId = job.id;
|
||||||
data.smartStatus = job.status;
|
data.smartStatus = job.status;
|
||||||
data.finished = $filter('longDate')(job.finished);
|
data.finished = $filter('longDate')(job.finished) || "running";
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
});
|
}), "finished").reverse();
|
||||||
|
|
||||||
$scope.sparkArray = _.pluck(sparkData, 'value');
|
$scope.sparkArray = _.pluck(sparkData, 'value');
|
||||||
$scope.jobIds = _.pluck(sparkData, 'jobId');
|
$scope.jobIds = _.pluck(sparkData, 'jobId');
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ export default [ function() {
|
|||||||
//capitalize first letter
|
//capitalize first letter
|
||||||
if (status) {
|
if (status) {
|
||||||
status = status.charAt(0).toUpperCase() + status.slice(1);
|
status = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
return "<div class=\"smart-status-tooltip\">Job ID: " +
|
var tooltip = "<div class=\"smart-status-tooltip\">Job ID: " +
|
||||||
options.userOptions.tooltipValueLookups.jobs[point.offset] +
|
options.userOptions.tooltipValueLookups.jobs[point.offset] +
|
||||||
"<br>Status: <span style=\"color: " + point.color + "\">●</span>"+status+
|
"<br>Status: <span style=\"color: " + point.color + "\">●</span>"+status;
|
||||||
"<br>Finished: " + finished +"</div>" ;
|
if (finished !== "running") {
|
||||||
|
tooltip += "<br>Finished: " + finished +"</div>" ;
|
||||||
|
}
|
||||||
|
return tooltip;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import compareFlatFacts from './compare-facts/flat';
|
|||||||
|
|
||||||
export function compareFacts(module, facts) {
|
export function compareFacts(module, facts) {
|
||||||
if (module.displayType === 'nested') {
|
if (module.displayType === 'nested') {
|
||||||
return compareNestedFacts(facts);
|
return { factData: compareNestedFacts(facts),
|
||||||
|
isNestedDisplay: true
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// For flat structures we compare left-to-right, then right-to-left to
|
// For flat structures we compare left-to-right, then right-to-left to
|
||||||
// make sure we get a good comparison between both hosts
|
// make sure we get a good comparison between both hosts
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export function formatFacts(diffedResults) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findFacts(factData) {
|
export function findFacts(factData) {
|
||||||
var rightData = factData[0];
|
var rightData = factData[0].facts;
|
||||||
var leftData = factData[1];
|
var leftData = factData[1].facts;
|
||||||
|
|
||||||
function factObject(keyPath, key, leftValue, rightValue) {
|
function factObject(keyPath, key, leftValue, rightValue) {
|
||||||
var obj =
|
var obj =
|
||||||
|
|||||||
@@ -45,11 +45,10 @@ function controller($rootScope,
|
|||||||
$scope.leftHostname = hosts[0].name;
|
$scope.leftHostname = hosts[0].name;
|
||||||
$scope.rightHostname = hosts.length > 1 ? hosts[1].name : hosts[0].name;
|
$scope.rightHostname = hosts.length > 1 ? hosts[1].name : hosts[0].name;
|
||||||
|
|
||||||
function reloadData(params, initialData) {
|
function reloadData(params) {
|
||||||
|
|
||||||
searchConfig = _.assign({}, searchConfig, params);
|
searchConfig = _.assign({}, searchConfig, params);
|
||||||
|
|
||||||
var factData = initialData;
|
|
||||||
var leftRange = searchConfig.leftRange;
|
var leftRange = searchConfig.leftRange;
|
||||||
var rightRange = searchConfig.rightRange;
|
var rightRange = searchConfig.rightRange;
|
||||||
var activeModule = searchConfig.module;
|
var activeModule = searchConfig.module;
|
||||||
@@ -80,9 +79,6 @@ function controller($rootScope,
|
|||||||
// arrays in index 1
|
// arrays in index 1
|
||||||
//
|
//
|
||||||
|
|
||||||
// Save the position of the data so we
|
|
||||||
// don't lose it later
|
|
||||||
|
|
||||||
var wrappedFacts =
|
var wrappedFacts =
|
||||||
facts.map(function(facts, index) {
|
facts.map(function(facts, index) {
|
||||||
return { position: index === 0 ? 'left' : 'right',
|
return { position: index === 0 ? 'left' : 'right',
|
||||||
|
|||||||
@@ -1984,3 +1984,7 @@ tr td button i {
|
|||||||
.job-stdout-panel {
|
.job-stdout-panel {
|
||||||
margin: 0 15px;
|
margin: 0 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ body {
|
|||||||
font-family: 'Open Sans', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @black;
|
color: @black;
|
||||||
padding-top: 75px;
|
padding-top: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-menu-container {
|
#main-menu-container {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="panel panel-default job-stdout-panel">
|
<div class="panel panel-default job-stdout-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h3 class="panel-title">{{ job.name }} standard out</h3>
|
<h3 class="panel-title">Standard Out</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body stdout-panel-body">
|
<div class="panel-body stdout-panel-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
|
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h3 class="panel-title">{{ job.name }} standard out</h3>
|
<h3 class="panel-title">Standard Out</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body stdout-panel-body">
|
<div class="panel-body stdout-panel-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
Reference in New Issue
Block a user