mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into alerting_definitions
This commit is contained in:
13
.flooignore
Normal file
13
.flooignore
Normal file
@@ -0,0 +1,13 @@
|
||||
#*
|
||||
*.o
|
||||
*.pyc
|
||||
*.pyo
|
||||
*~
|
||||
extern/
|
||||
node_modules/
|
||||
tmp/
|
||||
data/
|
||||
vendor/
|
||||
public_gen/
|
||||
dist/
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,4 +1,11 @@
|
||||
# 3.0.2 Stable (unreleased)
|
||||
# 3.1.0
|
||||
|
||||
### Enhancements
|
||||
* **Singlestat**: Add support for range to text mappings, closes [#1319](https://github.com/grafana/grafana/issues/1319)
|
||||
* **Graph**: Adds sort order options for graph tooltip, closes [#1189](https://github.com/grafana/grafana/issues/1189)
|
||||
* **Theme**: Add default theme to config file [#5011](https://github.com/grafana/grafana/pull/5011)
|
||||
|
||||
# 3.0.2 Stable (2016-05-16)
|
||||
|
||||
* **Templating**: Fixed issue mixing row repeat and panel repeats, fixes [#4988](https://github.com/grafana/grafana/issues/4988)
|
||||
* **Templating**: Fixed issue detecting dependencies in nested variables, fixes [#4987](https://github.com/grafana/grafana/issues/4987), fixes [#4986](https://github.com/grafana/grafana/issues/4986)
|
||||
@@ -9,6 +16,7 @@
|
||||
|
||||
# 3.0.1 Stable (2016-05-11)
|
||||
|
||||
### Bug fixes
|
||||
* **Templating**: Fixed issue with new data source variable not persisting current selected value, fixes [#4934](https://github.com/grafana/grafana/issues/4934)
|
||||
|
||||
# 3.0.0-beta7 (2016-05-02)
|
||||
|
||||
@@ -172,6 +172,9 @@ verify_email_enabled = false
|
||||
# Background text for the user field on the login page
|
||||
login_hint = email or username
|
||||
|
||||
# Default UI theme ("dark" or "light")
|
||||
default_theme = dark
|
||||
|
||||
#################################### Anonymous Auth ##########################
|
||||
[auth.anonymous]
|
||||
# enable anonymous access
|
||||
|
||||
@@ -155,6 +155,9 @@ check_for_updates = true
|
||||
# Background text for the user field on the login page
|
||||
;login_hint = email or username
|
||||
|
||||
# Default UI theme ("dark" or "light")
|
||||
;default_theme = dark
|
||||
|
||||
#################################### Anonymous Auth ##########################
|
||||
[auth.anonymous]
|
||||
# enable anonymous access
|
||||
|
||||
@@ -8,3 +8,10 @@ graphite:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
|
||||
fake-data-gen:
|
||||
image: grafana/fake-data-gen
|
||||
net: bridge
|
||||
environment:
|
||||
FD_DATASOURCE: graphite
|
||||
FD_PORT: 2003
|
||||
|
||||
|
||||
@@ -2,4 +2,10 @@ opentsdb:
|
||||
image: opower/opentsdb:latest
|
||||
ports:
|
||||
- "4242:4242"
|
||||
|
||||
|
||||
fake-data-gen:
|
||||
image: grafana/fake-data-gen
|
||||
net: bridge
|
||||
environment:
|
||||
FD_DATASOURCE: opentsdb
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ page_keywords: grafana, installation, debian, ubuntu, guide
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable .deb for Debian-based Linux | [grafana_3.0.1_amd64.deb](https://grafanarel.s3.amazonaws.com/builds/grafana_3.0.1_amd64.deb)
|
||||
Stable .deb for Debian-based Linux | [grafana_3.0.2-1463383025_amd64.deb](https://grafanarel.s3.amazonaws.com/builds/grafana_3.0.2-1463383025_amd64.deb)
|
||||
|
||||
## Install Stable
|
||||
|
||||
$ wget https://grafanarel.s3.amazonaws.com/builds/grafana_3.0.1_amd64.deb
|
||||
$ wget https://grafanarel.s3.amazonaws.com/builds/grafana_3.0.2-1463383025_amd64.deb
|
||||
$ sudo apt-get install -y adduser libfontconfig
|
||||
$ sudo dpkg -i grafana_3.0.1_amd64.deb
|
||||
$ sudo dpkg -i grafana_3.0.2-1463383025_amd64.deb
|
||||
|
||||
## APT Repository
|
||||
|
||||
|
||||
@@ -10,24 +10,24 @@ page_keywords: grafana, installation, centos, fedora, opensuse, redhat, guide
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable .RPM for CentOS / Fedora / OpenSuse / Redhat Linux | [grafana-3.0.1-1.x86_64.rpm](https://grafanarel.s3.amazonaws.com/builds/grafana-3.0.1-1.x86_64.rpm)
|
||||
Stable .RPM for CentOS / Fedora / OpenSuse / Redhat Linux | [grafana-3.0.2-1463383025.x86_64.rpm](https://grafanarel.s3.amazonaws.com/builds/grafana-3.0.2-1463383025.x86_64.rpm)
|
||||
|
||||
## Install Stable Release from package file
|
||||
|
||||
You can install Grafana using Yum directly.
|
||||
|
||||
$ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-3.0.1-1.x86_64.rpm
|
||||
$ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-3.0.2-1463383025.x86_64.rpm
|
||||
|
||||
Or install manually using `rpm`.
|
||||
|
||||
#### On CentOS / Fedora / Redhat:
|
||||
|
||||
$ sudo yum install initscripts fontconfig
|
||||
$ sudo rpm -Uvh grafana-3.0.1-1.x86_64.rpm
|
||||
$ sudo rpm -Uvh grafana-3.0.2-1463383025.x86_64.rpm
|
||||
|
||||
#### On OpenSuse:
|
||||
|
||||
$ sudo rpm -i --nodeps grafana-3.0.1-1.x86_64.rpm
|
||||
$ sudo rpm -i --nodeps grafana-3.0.2-1463383025.x86_64.rpm
|
||||
|
||||
## Install via YUM Repository
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ page_keywords: grafana, installation, windows guide
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable Zip package for Windows | [grafana.2.6.0.windows-x64.zip](https://grafanarel.s3.amazonaws.com/winbuilds/dist/grafana-2.5.0.windows-x64.zip)
|
||||
Stable Zip package for Windows | [grafana.3.0.2.windows-x64.zip](https://grafanarel.s3.amazonaws.com/winbuilds/dist/grafana-3.0.2.windows-x64.zip)
|
||||
|
||||
## Configure
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"company": "Coding Instinct AB"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "3.0.2",
|
||||
"version": "3.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -26,7 +28,7 @@ func GetPreferencesWithDefaults(query *m.GetPreferencesWithDefaultsQuery) error
|
||||
}
|
||||
|
||||
res := &m.Preferences{
|
||||
Theme: "dark",
|
||||
Theme: setting.DefaultTheme,
|
||||
Timezone: "browser",
|
||||
HomeDashboardId: 0,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ var (
|
||||
AutoAssignOrgRole string
|
||||
VerifyEmailEnabled bool
|
||||
LoginHint string
|
||||
DefaultTheme string
|
||||
|
||||
// Http auth
|
||||
AdminUser string
|
||||
@@ -457,6 +458,7 @@ func NewConfigContext(args *CommandLineArgs) error {
|
||||
AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Read Only Editor", "Viewer"})
|
||||
VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false)
|
||||
LoginHint = users.Key("login_hint").String()
|
||||
DefaultTheme = users.Key("default_theme").String()
|
||||
|
||||
// anonymous access
|
||||
AnonymousEnabled = Cfg.Section("auth.anonymous").Key("enabled").MustBool(false)
|
||||
|
||||
@@ -81,9 +81,9 @@ function ($) {
|
||||
// Stacked series can increase its length on each new stacked serie if null points found,
|
||||
// to speed the index search we begin always on the last found hoverIndex.
|
||||
var newhoverIndex = this.findHoverIndexFromDataPoints(pos.x, series, hoverIndex);
|
||||
results.push({ value: value, hoverIndex: newhoverIndex });
|
||||
results.push({ value: value, hoverIndex: newhoverIndex, color: series.color, label: series.label });
|
||||
} else {
|
||||
results.push({ value: value, hoverIndex: hoverIndex });
|
||||
results.push({ value: value, hoverIndex: hoverIndex, color: series.color, label: series.label });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,18 @@ function ($) {
|
||||
|
||||
absoluteTime = dashboard.formatDate(seriesHoverInfo.time, tooltipFormat);
|
||||
|
||||
// Dynamically reorder the hovercard for the current time point if the
|
||||
// option is enabled.
|
||||
if (panel.tooltip.ordering === 'decreasing') {
|
||||
seriesHoverInfo.sort(function(a, b) {
|
||||
return parseFloat(b.value) - parseFloat(a.value);
|
||||
});
|
||||
} else if (panel.tooltip.ordering === 'increasing') {
|
||||
seriesHoverInfo.sort(function(a, b) {
|
||||
return parseFloat(a.value) - parseFloat(b.value);
|
||||
});
|
||||
}
|
||||
|
||||
for (i = 0; i < seriesHoverInfo.length; i++) {
|
||||
hoverInfo = seriesHoverInfo[i];
|
||||
|
||||
@@ -150,7 +162,7 @@ function ($) {
|
||||
value = series.formatValue(hoverInfo.value);
|
||||
|
||||
seriesHtml += '<div class="graph-tooltip-list-item ' + highlightClass + '"><div class="graph-tooltip-series-name">';
|
||||
seriesHtml += '<i class="fa fa-minus" style="color:' + series.color +';"></i> ' + series.label + ':</div>';
|
||||
seriesHtml += '<i class="fa fa-minus" style="color:' + hoverInfo.color +';"></i> ' + hoverInfo.label + ':</div>';
|
||||
seriesHtml += '<div class="graph-tooltip-value">' + value + '</div></div>';
|
||||
plot.highlight(i, hoverInfo.hoverIndex);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ class GraphCtrl extends MetricsPanelCtrl {
|
||||
tooltip : {
|
||||
value_type: 'cumulative',
|
||||
shared: true,
|
||||
ordering: 'alphabetical',
|
||||
msResolution: false,
|
||||
},
|
||||
// time overrides
|
||||
|
||||
@@ -42,23 +42,29 @@
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Misc options</h5>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Null value</label>
|
||||
<label class="gf-form-label width-9">Null value</label>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input max-width-8" ng-model="ctrl.panel.nullPointMode" ng-options="f for f in ['connected', 'null', 'null as zero']" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Renderer</label>
|
||||
<label class="gf-form-label width-9">Renderer</label>
|
||||
<div class="gf-form-select-wrapper max-width-8">
|
||||
<select class="gf-form-input" ng-model="ctrl.panel.renderer" ng-options="f for f in ['flot', 'png']" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Tooltip mode</label>
|
||||
<label class="gf-form-label width-9">Tooltip mode</label>
|
||||
<div class="gf-form-select-wrapper max-width-8">
|
||||
<select class="gf-form-input" ng-model="ctrl.panel.tooltip.shared" ng-options="f.value as f.text for f in [{text: 'All series', value: true}, {text: 'Single', value: false}]" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-9">Tooltip ordering<tip>The ordering from top to bottom</tip></label>
|
||||
<div class="gf-form-select-wrapper max-width-8">
|
||||
<select class="gf-form-input" ng-model="ctrl.panel.tooltip.ordering" ng-options="f.value as f.text for f in [{text: 'Alphabetical', value: 'alphabetical'}, {text: 'Increasing', value: 'increasing'}, {text: 'Decreasing', value: 'decreasing'}]" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section gf-form-group">
|
||||
|
||||
@@ -204,35 +204,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<div class="section" style="margin-bottom: 20px">
|
||||
<div class="tight-form last">
|
||||
<ul class="tight-form-list">
|
||||
<li class="tight-form-item">
|
||||
<strong>Value to text mapping</strong>
|
||||
</li>
|
||||
<li class="tight-form-item" ng-repeat-start="map in ctrl.panel.valueMaps">
|
||||
<i class="fa fa-remove pointer" ng-click="ctrl.removeValueMap(map)"></i>
|
||||
</li>
|
||||
<li>
|
||||
<input type="text" ng-model="map.value" placeholder="value" class="input-mini tight-form-input" ng-blur="ctrl.render()">
|
||||
</li>
|
||||
<li class="tight-form-item">
|
||||
<i class="fa fa-arrow-right"></i>
|
||||
</li>
|
||||
<li ng-repeat-end>
|
||||
<input type="text" placeholder="text" ng-model="map.text" class="input-mini tight-form-input" ng-blur="ctrl.render()">
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="pointer tight-form-item last" ng-click="ctrl.addValueMap();">
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
58
public/app/plugins/panel/singlestat/mappings.html
Normal file
58
public/app/plugins/panel/singlestat/mappings.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<div class="editor-row">
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label">
|
||||
Type
|
||||
</span>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.panel.mappingType"
|
||||
ng-options="f.value as f.name for f in ctrl.panel.mappingTypes" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-row" ng-if="ctrl.panel.mappingType==1">
|
||||
<h5 class="page-heading">Set valuea mappings</h5>
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form" ng-repeat="map in ctrl.panel.valueMaps">
|
||||
<span class="gf-form-label">
|
||||
<i class="fa fa-remove pointer" ng-click="ctrl.removeValueMap(map)"></i>
|
||||
</span>
|
||||
<input type="text" ng-model="map.value" placeholder="value" class="gf-form-input max-width-6" ng-blur="ctrl.render()">
|
||||
<span class="gf-form-label">
|
||||
<i class="fa fa-arrow-right"></i>
|
||||
</span>
|
||||
<input type="text" placeholder="text" ng-model="map.text" class="gf-form-input max-width-8" ng-blur="ctrl.render()">
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row">
|
||||
<button class="btn btn-inverse" ng-click="ctrl.addValueMap();">
|
||||
<i class="fa fa-plus"></i>
|
||||
Add a value mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-row" ng-if="ctrl.panel.mappingType==2">
|
||||
<h5 class="page-heading">Set range mappings</h5>
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form" ng-repeat="rangeMap in ctrl.panel.rangeMaps">
|
||||
<span class="gf-form-label">
|
||||
<i class="fa fa-remove pointer" ng-click="ctrl.removeRangeMap(rangeMap)"></i>
|
||||
</span>
|
||||
<span class="gf-form-label">From</span>
|
||||
<input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="ctrl.render()">
|
||||
<span class="gf-form-label">To</span>
|
||||
<input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="ctrl.render()">
|
||||
<span class="gf-form-label">Text</span>
|
||||
<input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="ctrl.render()">
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row">
|
||||
<button class="btn btn-inverse" ng-click="ctrl.addRangeMap()">
|
||||
<i class="fa fa-plus"></i>
|
||||
Add a range mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,6 +35,14 @@ class SingleStatCtrl extends MetricsPanelCtrl {
|
||||
valueMaps: [
|
||||
{ value: 'null', op: '=', text: 'N/A' }
|
||||
],
|
||||
mappingTypes: [
|
||||
{name: 'value to text', value: 1},
|
||||
{name: 'range to text', value: 2},
|
||||
],
|
||||
rangeMaps: [
|
||||
{ from: 'null', to: 'null', text: 'N/A' }
|
||||
],
|
||||
mappingType: 1,
|
||||
nullPointMode: 'connected',
|
||||
valueName: 'avg',
|
||||
prefixFontSize: '50%',
|
||||
@@ -73,6 +81,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
|
||||
onInitEditMode() {
|
||||
this.fontSizes = ['20%', '30%','50%','70%','80%','100%', '110%', '120%', '150%', '170%', '200%'];
|
||||
this.addEditorTab('Options', 'public/app/plugins/panel/singlestat/editor.html', 2);
|
||||
this.addEditorTab('Value Mappings', 'public/app/plugins/panel/singlestat/mappings.html', 3);
|
||||
this.unitFormats = kbn.getUnitFormats();
|
||||
}
|
||||
|
||||
@@ -192,23 +201,45 @@ class SingleStatCtrl extends MetricsPanelCtrl {
|
||||
}
|
||||
}
|
||||
|
||||
// check value to text mappings
|
||||
for (var i = 0; i < this.panel.valueMaps.length; i++) {
|
||||
var map = this.panel.valueMaps[i];
|
||||
// special null case
|
||||
if (map.value === 'null') {
|
||||
if (data.value === null || data.value === void 0) {
|
||||
// check value to text mappings if its enabled
|
||||
if (this.panel.mappingType === 1) {
|
||||
for (var i = 0; i < this.panel.valueMaps.length; i++) {
|
||||
var map = this.panel.valueMaps[i];
|
||||
// special null case
|
||||
if (map.value === 'null') {
|
||||
if (data.value === null || data.value === void 0) {
|
||||
data.valueFormated = map.text;
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// value/number to text mapping
|
||||
var value = parseFloat(map.value);
|
||||
if (value === data.valueRounded) {
|
||||
data.valueFormated = map.text;
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else if (this.panel.mappingType === 2) {
|
||||
for (var i = 0; i < this.panel.rangeMaps.length; i++) {
|
||||
var map = this.panel.rangeMaps[i];
|
||||
// special null case
|
||||
if (map.from === 'null' && map.to === 'null') {
|
||||
if (data.value === null || data.value === void 0) {
|
||||
data.valueFormated = map.text;
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// value/number to text mapping
|
||||
var value = parseFloat(map.value);
|
||||
if (value === data.valueRounded) {
|
||||
data.valueFormated = map.text;
|
||||
return;
|
||||
// value/number to range mapping
|
||||
var from = parseFloat(map.from);
|
||||
var to = parseFloat(map.to);
|
||||
if (to >= data.valueRounded && from <= data.valueRounded) {
|
||||
data.valueFormated = map.text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +258,16 @@ class SingleStatCtrl extends MetricsPanelCtrl {
|
||||
this.panel.valueMaps.push({value: '', op: '=', text: '' });
|
||||
}
|
||||
|
||||
removeRangeMap(rangeMap) {
|
||||
var index = _.indexOf(this.panel.rangeMaps, rangeMap);
|
||||
this.panel.rangeMaps.splice(index, 1);
|
||||
this.render();
|
||||
};
|
||||
|
||||
addRangeMap() {
|
||||
this.panel.rangeMaps.push({from: '', to: '', text: ''});
|
||||
}
|
||||
|
||||
link(scope, elem, attrs, ctrl) {
|
||||
var $location = this.$location;
|
||||
var linkSrv = this.linkSrv;
|
||||
|
||||
@@ -84,4 +84,29 @@ describe('SingleStatCtrl', function() {
|
||||
expect(ctx.data.valueFormated).to.be('OK');
|
||||
});
|
||||
});
|
||||
|
||||
singleStatScenario('When range to text mapping is specifiedfor first range', function(ctx) {
|
||||
ctx.setup(function() {
|
||||
ctx.datapoints = [[41,50]];
|
||||
ctx.ctrl.panel.mappingType = 2;
|
||||
ctx.ctrl.panel.rangeMaps = [{from: '10', to: '50', text: 'OK'},{from: '51', to: '100', text: 'NOT OK'}];
|
||||
});
|
||||
|
||||
it('Should replace value with text OK', function() {
|
||||
expect(ctx.data.valueFormated).to.be('OK');
|
||||
});
|
||||
});
|
||||
|
||||
singleStatScenario('When range to text mapping is specified for other ranges', function(ctx) {
|
||||
ctx.setup(function() {
|
||||
ctx.datapoints = [[65,75]];
|
||||
ctx.ctrl.panel.mappingType = 2;
|
||||
ctx.ctrl.panel.rangeMaps = [{from: '10', to: '50', text: 'OK'},{from: '51', to: '100', text: 'NOT OK'}];
|
||||
});
|
||||
|
||||
it('Should replace value with text NOT OK', function() {
|
||||
expect(ctx.data.valueFormated).to.be('NOT OK');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user