Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
N
nexpie-grafana-theme
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Registry
Registry
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Kornkitt Poolsup
nexpie-grafana-theme
Commits
bf98cfea
Commit
bf98cfea
authored
Sep 28, 2015
by
Jimmi Dyson
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add prometheus datasource
parent
cb7424ce
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
626 additions
and
0 deletions
+626
-0
public/app/plugins/datasource/prometheus/datasource.js
+256
-0
public/app/plugins/datasource/prometheus/directives.js
+13
-0
public/app/plugins/datasource/prometheus/partials/config.html
+4
-0
public/app/plugins/datasource/prometheus/partials/query.editor.html
+143
-0
public/app/plugins/datasource/prometheus/plugin.json
+15
-0
public/app/plugins/datasource/prometheus/queryCtrl.js
+133
-0
public/test/specs/prometheus-datasource-specs.js
+61
-0
tasks/options/requirejs.js
+1
-0
No files found.
public/app/plugins/datasource/prometheus/datasource.js
0 → 100644
View file @
bf98cfea
define
([
'angular'
,
'lodash'
,
'kbn'
,
'moment'
,
'app/core/utils/datemath'
,
'./directives'
,
'./queryCtrl'
,
],
function
(
angular
,
_
,
kbn
,
dateMath
)
{
'use strict'
;
var
module
=
angular
.
module
(
'grafana.services'
);
module
.
factory
(
'PrometheusDatasource'
,
function
(
$q
,
backendSrv
,
templateSrv
)
{
function
PrometheusDatasource
(
datasource
)
{
this
.
type
=
'prometheus'
;
this
.
editorSrc
=
'app/features/prometheus/partials/query.editor.html'
;
this
.
name
=
datasource
.
name
;
this
.
supportMetrics
=
true
;
var
url
=
datasource
.
url
;
if
(
url
[
url
.
length
-
1
]
===
'/'
)
{
// remove trailing slash
url
=
url
.
substr
(
0
,
url
.
length
-
1
);
}
this
.
url
=
url
;
this
.
basicAuth
=
datasource
.
basicAuth
;
this
.
lastErrors
=
{};
}
PrometheusDatasource
.
prototype
.
_request
=
function
(
method
,
url
)
{
var
options
=
{
url
:
this
.
url
+
url
,
method
:
method
};
if
(
this
.
basicAuth
)
{
options
.
withCredentials
=
true
;
options
.
headers
=
{
"Authorization"
:
this
.
basicAuth
};
}
return
backendSrv
.
datasourceRequest
(
options
);
};
// Called once per panel (graph)
PrometheusDatasource
.
prototype
.
query
=
function
(
options
)
{
var
start
=
getPrometheusTime
(
options
.
range
.
from
,
false
);
var
end
=
getPrometheusTime
(
options
.
range
.
to
,
true
);
var
queries
=
[];
_
.
each
(
options
.
targets
,
_
.
bind
(
function
(
target
)
{
if
(
!
target
.
expr
||
target
.
hide
)
{
return
;
}
var
query
=
{};
query
.
expr
=
templateSrv
.
replace
(
target
.
expr
,
options
.
scopedVars
);
var
interval
=
target
.
interval
||
options
.
interval
;
var
intervalFactor
=
target
.
intervalFactor
||
1
;
query
.
step
=
this
.
calculateInterval
(
interval
,
intervalFactor
);
queries
.
push
(
query
);
},
this
));
// No valid targets, return the empty result to save a round trip.
if
(
_
.
isEmpty
(
queries
))
{
var
d
=
$q
.
defer
();
d
.
resolve
({
data
:
[]
});
return
d
.
promise
;
}
var
allQueryPromise
=
_
.
map
(
queries
,
_
.
bind
(
function
(
query
)
{
return
this
.
performTimeSeriesQuery
(
query
,
start
,
end
);
},
this
));
var
self
=
this
;
return
$q
.
all
(
allQueryPromise
)
.
then
(
function
(
allResponse
)
{
var
result
=
[];
_
.
each
(
allResponse
,
function
(
response
,
index
)
{
if
(
response
.
status
===
'error'
)
{
self
.
lastErrors
.
query
=
response
.
error
;
throw
response
.
error
;
}
delete
self
.
lastErrors
.
query
;
_
.
each
(
response
.
data
.
data
.
result
,
function
(
metricData
)
{
result
.
push
(
transformMetricData
(
metricData
,
options
.
targets
[
index
]));
});
});
return
{
data
:
result
};
});
};
PrometheusDatasource
.
prototype
.
performTimeSeriesQuery
=
function
(
query
,
start
,
end
)
{
var
url
=
'/api/v1/query_range?query='
+
encodeURIComponent
(
query
.
expr
)
+
'&start='
+
start
+
'&end='
+
end
;
var
step
=
query
.
step
;
var
range
=
Math
.
floor
(
end
-
start
);
// Prometheus drop query if range/step > 11000
// calibrate step if it is too big
if
(
step
!==
0
&&
range
/
step
>
11000
)
{
step
=
Math
.
floor
(
range
/
11000
);
}
url
+=
'&step='
+
step
;
return
this
.
_request
(
'GET'
,
url
);
};
PrometheusDatasource
.
prototype
.
performSuggestQuery
=
function
(
query
)
{
var
url
=
'/api/v1/label/__name__/values'
;
return
this
.
_request
(
'GET'
,
url
).
then
(
function
(
result
)
{
var
suggestData
=
_
.
filter
(
result
.
data
.
data
,
function
(
metricName
)
{
return
metricName
.
indexOf
(
query
)
!==
1
;
});
return
suggestData
;
});
};
PrometheusDatasource
.
prototype
.
metricFindQuery
=
function
(
query
)
{
var
url
;
var
metricsQuery
=
query
.
match
(
/^
[
a-zA-Z_:*
][
a-zA-Z0-9_:*
]
*/
);
var
labelValuesQuery
=
query
.
match
(
/^label_values
\((
.+
)\)
/
);
if
(
labelValuesQuery
)
{
// return label values
url
=
'/api/v1/label/'
+
labelValuesQuery
[
1
]
+
'/values'
;
return
this
.
_request
(
'GET'
,
url
).
then
(
function
(
result
)
{
return
_
.
map
(
result
.
data
.
data
,
function
(
value
)
{
return
{
text
:
value
};
});
});
}
else
if
(
metricsQuery
!=
null
&&
metricsQuery
[
0
].
indexOf
(
'*'
)
>=
0
)
{
// if query has wildcard character, return metric name list
url
=
'/api/v1/label/__name__/values'
;
return
this
.
_request
(
'GET'
,
url
)
.
then
(
function
(
result
)
{
return
_
.
chain
(
result
.
data
.
data
)
.
filter
(
function
(
metricName
)
{
var
r
=
new
RegExp
(
metricsQuery
[
0
].
replace
(
/
\*
/g
,
'.*'
));
return
r
.
test
(
metricName
);
})
.
map
(
function
(
matchedMetricName
)
{
return
{
text
:
matchedMetricName
,
expandable
:
true
};
})
.
value
();
});
}
else
{
// if query contains full metric name, return metric name and label list
url
=
'/api/v1/query?query='
+
encodeURIComponent
(
query
);
return
this
.
_request
(
'GET'
,
url
)
.
then
(
function
(
result
)
{
return
_
.
map
(
result
.
data
.
result
,
function
(
metricData
)
{
return
{
text
:
getOriginalMetricName
(
metricData
.
metric
),
expandable
:
true
};
});
});
}
};
PrometheusDatasource
.
prototype
.
testDatasource
=
function
()
{
return
this
.
metricFindQuery
(
'*'
).
then
(
function
()
{
return
{
status
:
'success'
,
message
:
'Data source is working'
,
title
:
'Success'
};
});
};
PrometheusDatasource
.
prototype
.
calculateInterval
=
function
(
interval
,
intervalFactor
)
{
var
sec
=
kbn
.
interval_to_seconds
(
interval
);
if
(
sec
<
1
)
{
sec
=
1
;
}
return
sec
*
intervalFactor
;
};
function
transformMetricData
(
md
,
options
)
{
var
dps
=
[],
metricLabel
=
null
;
metricLabel
=
createMetricLabel
(
md
.
metric
,
options
);
dps
=
_
.
map
(
md
.
values
,
function
(
value
)
{
return
[
parseFloat
(
value
[
1
]),
value
[
0
]
*
1000
];
});
return
{
target
:
metricLabel
,
datapoints
:
dps
};
}
function
createMetricLabel
(
labelData
,
options
)
{
if
(
_
.
isUndefined
(
options
)
||
_
.
isEmpty
(
options
.
legendFormat
))
{
return
getOriginalMetricName
(
labelData
);
}
var
originalSettings
=
_
.
templateSettings
;
_
.
templateSettings
=
{
interpolate
:
/
\{\{(
.+
?)\}\}
/g
};
var
template
=
_
.
template
(
templateSrv
.
replace
(
options
.
legendFormat
));
var
metricName
;
try
{
metricName
=
template
(
labelData
);
}
catch
(
e
)
{
metricName
=
'{}'
;
}
_
.
templateSettings
=
originalSettings
;
return
metricName
;
}
function
getOriginalMetricName
(
labelData
)
{
var
metricName
=
labelData
.
__name__
||
''
;
delete
labelData
.
__name__
;
var
labelPart
=
_
.
map
(
_
.
pairs
(
labelData
),
function
(
label
)
{
return
label
[
0
]
+
'="'
+
label
[
1
]
+
'"'
;
}).
join
(
','
);
return
metricName
+
'{'
+
labelPart
+
'}'
;
}
function
getPrometheusTime
(
date
,
roundUp
)
{
if
(
_
.
isString
(
date
))
{
if
(
date
===
'now'
)
{
return
'now()'
;
}
if
(
date
.
indexOf
(
'now-'
)
>=
0
&&
date
.
indexOf
(
'/'
)
===
-
1
)
{
return
date
.
replace
(
'now'
,
'now()'
).
replace
(
'-'
,
' - '
);
}
date
=
dateMath
.
parse
(
date
,
roundUp
);
}
return
(
date
.
valueOf
()
/
1000
).
toFixed
(
0
);
}
return
PrometheusDatasource
;
});
});
public/app/plugins/datasource/prometheus/directives.js
0 → 100644
View file @
bf98cfea
define
([
'angular'
,
],
function
(
angular
)
{
'use strict'
;
var
module
=
angular
.
module
(
'grafana.directives'
);
module
.
directive
(
'metricQueryEditorPrometheus'
,
function
()
{
return
{
controller
:
'PrometheusQueryCtrl'
,
templateUrl
:
'app/plugins/datasource/prometheus/partials/query.editor.html'
};
});
});
public/app/plugins/datasource/prometheus/partials/config.html
0 → 100644
View file @
bf98cfea
<div
ng-include=
"httpConfigPartialSrc"
></div>
<br>
public/app/plugins/datasource/prometheus/partials/query.editor.html
0 → 100644
View file @
bf98cfea
<div
class=
"tight-form"
>
<ul
class=
"tight-form-list pull-right"
>
<li
class=
"tight-form-item small"
ng-show=
"target.datasource"
>
<em>
{{target.datasource}}
</em>
</li>
<li
class=
"tight-form-item"
>
<div
class=
"dropdown"
>
<a
class=
"pointer dropdown-toggle"
data-toggle=
"dropdown"
tabindex=
"1"
>
<i
class=
"fa fa-bars"
></i>
</a>
<ul
class=
"dropdown-menu pull-right"
role=
"menu"
>
<li
role=
"menuitem"
><a
tabindex=
"1"
ng-click=
"toggleQueryMode()"
>
Switch editor mode
</a></li>
<li
role=
"menuitem"
><a
tabindex=
"1"
ng-click=
"duplicateDataQuery(target)"
>
Duplicate
</a></li>
<li
role=
"menuitem"
><a
tabindex=
"1"
ng-click=
"moveDataQuery($index, $index-1)"
>
Move up
</a></li>
<li
role=
"menuitem"
><a
tabindex=
"1"
ng-click=
"moveDataQuery($index, $index+1)"
>
Move down
</a></li>
</ul>
</div>
</li>
<li
class=
"tight-form-item last"
>
<a
class=
"pointer"
tabindex=
"1"
ng-click=
"removeDataQuery(target)"
>
<i
class=
"fa fa-remove"
></i>
</a>
</li>
</ul>
<ul
class=
"tight-form-list"
>
<li
class=
"tight-form-item"
style=
"min-width: 15px; text-align: center"
>
{{target.refId}}
</li>
<li>
<a
class=
"tight-form-item"
ng-click=
"target.hide = !target.hide; get_data();"
role=
"menuitem"
>
<i
class=
"fa fa-eye"
></i>
</a>
</li>
</ul>
<ul
class=
"tight-form-list"
role=
"menu"
>
<li
class=
"tight-form-item"
style=
"width: 94px"
>
Query
</li>
<li>
<input
type=
"text"
class=
"input-xxlarge tight-form-input"
ng-model=
"target.expr"
spellcheck=
'false'
placeholder=
"query expression"
data-min-length=
0
data-items=
100
ng-model-onblur
ng-change=
"refreshMetricData()"
>
<a
bs-tooltip=
"target.datasourceErrors.query"
style=
"color: rgb(229, 189, 28)"
ng-show=
"target.datasourceErrors.query"
>
<i
class=
"fa fa-warning"
></i>
</a>
</li>
<li
class=
"tight-form-item"
>
Metric
</li>
<li>
<input
type=
"text"
class=
"input-medium tight-form-input"
ng-model=
"target.metric"
spellcheck=
'false'
bs-typeahead=
"suggestMetrics"
placeholder=
"metric name"
data-min-length=
0
data-items=
100
>
<a
bs-tooltip=
"target.errors.metric"
style=
"color: rgb(229, 189, 28)"
ng-show=
"target.errors.metric"
>
<i
class=
"fa fa-warning"
></i>
</a>
</li>
</ul>
<div
class=
"clearfix"
></div>
</div>
<div
class=
"tight-form"
>
<ul
class=
"tight-form-list"
role=
"menu"
>
<li
class=
"tight-form-item tight-form-align"
style=
"width: 94px"
>
Legend format
</li>
<li>
<input
type=
"text"
class=
"tight-form-input input-xxlarge"
ng-model=
"target.legendFormat"
spellcheck=
'false'
placeholder=
"legend format"
data-min-length=
0
data-items=
1000
ng-model-onblur
ng-change=
"refreshMetricData()"
/>
</li>
</ul>
<div
class=
"clearfix"
></div>
</div>
<div
class=
"tight-form"
>
<ul
class=
"tight-form-list"
role=
"menu"
>
<li
class=
"tight-form-item tight-form-align"
style=
"width: 94px"
>
Step
</li>
<li>
<input
type=
"text"
class=
"input-mini tight-form-input"
ng-model=
"target.interval"
bs-tooltip=
"'Leave blank for auto handling based on time range and panel width'"
data-placement=
"right"
spellcheck=
'false'
placeholder=
"{{target.calculatedInterval}}"
data-min-length=
0
data-items=
100
ng-model-onblur
ng-change=
"refreshMetricData()"
/>
</li>
<li
class=
"tight-form-item"
>
Resolution
</li>
<li>
<select
ng-model=
"target.intervalFactor"
class=
"tight-form-input input-mini"
ng-options=
"r.factor as r.label for r in resolutions"
ng-change=
"refreshMetricData()"
>
</select>
</li>
<li
class=
"tight-form-item"
>
<a
href=
"{{target.prometheusLink}}"
target=
"_blank"
bs-tooltip=
"'Link to Graph in Prometheus'"
>
<i
class=
"fa fa-share-square-o"
></i>
</a>
</li>
</ul>
<div
class=
"clearfix"
></div>
</div>
public/app/plugins/datasource/prometheus/plugin.json
0 → 100644
View file @
bf98cfea
{
"pluginType"
:
"datasource"
,
"name"
:
"Prometheus"
,
"type"
:
"prometheus"
,
"serviceName"
:
"PrometheusDatasource"
,
"module"
:
"app/plugins/datasource/prometheus/datasource"
,
"partials"
:
{
"config"
:
"app/plugins/datasource/prometheus/partials/config.html"
},
"metrics"
:
true
}
public/app/plugins/datasource/prometheus/queryCtrl.js
0 → 100644
View file @
bf98cfea
define
([
'angular'
,
'lodash'
,
'kbn'
,
'app/core/utils/datemath'
,
],
function
(
angular
,
_
,
kbn
,
dateMath
)
{
'use strict'
;
var
module
=
angular
.
module
(
'grafana.controllers'
);
module
.
controller
(
'PrometheusQueryCtrl'
,
function
(
$scope
)
{
$scope
.
init
=
function
()
{
$scope
.
target
.
errors
=
validateTarget
();
$scope
.
target
.
datasourceErrors
=
{};
if
(
!
$scope
.
target
.
expr
)
{
$scope
.
target
.
expr
=
''
;
}
$scope
.
target
.
metric
=
''
;
$scope
.
resolutions
=
[
{
factor
:
1
,
},
{
factor
:
2
,
},
{
factor
:
3
,
},
{
factor
:
5
,
},
{
factor
:
10
,
},
];
$scope
.
resolutions
=
_
.
map
(
$scope
.
resolutions
,
function
(
r
)
{
r
.
label
=
'1/'
+
r
.
factor
;
return
r
;
});
if
(
!
$scope
.
target
.
intervalFactor
)
{
$scope
.
target
.
intervalFactor
=
2
;
// default resolution is 1/2
}
$scope
.
calculateInterval
();
$scope
.
$on
(
'render'
,
function
()
{
$scope
.
calculateInterval
();
// re-calculate interval when time range is updated
});
$scope
.
target
.
prometheusLink
=
$scope
.
linkToPrometheus
();
$scope
.
$on
(
'typeahead-updated'
,
function
()
{
$scope
.
$apply
(
$scope
.
inputMetric
);
$scope
.
refreshMetricData
();
});
$scope
.
datasource
.
lastErrors
=
{};
$scope
.
$watch
(
'datasource.lastErrors'
,
function
()
{
$scope
.
target
.
datasourceErrors
=
$scope
.
datasource
.
lastErrors
;
},
true
);
};
$scope
.
refreshMetricData
=
function
()
{
$scope
.
target
.
errors
=
validateTarget
(
$scope
.
target
);
$scope
.
calculateInterval
();
$scope
.
target
.
prometheusLink
=
$scope
.
linkToPrometheus
();
// this does not work so good
if
(
!
_
.
isEqual
(
$scope
.
oldTarget
,
$scope
.
target
)
&&
_
.
isEmpty
(
$scope
.
target
.
errors
))
{
$scope
.
oldTarget
=
angular
.
copy
(
$scope
.
target
);
$scope
.
get_data
();
}
};
$scope
.
inputMetric
=
function
()
{
$scope
.
target
.
expr
+=
$scope
.
target
.
metric
;
$scope
.
target
.
metric
=
''
;
};
$scope
.
moveMetricQuery
=
function
(
fromIndex
,
toIndex
)
{
_
.
move
(
$scope
.
panel
.
targets
,
fromIndex
,
toIndex
);
};
$scope
.
suggestMetrics
=
function
(
query
,
callback
)
{
$scope
.
datasource
.
performSuggestQuery
(
query
)
.
then
(
callback
);
};
$scope
.
linkToPrometheus
=
function
()
{
var
from
=
dateMath
.
parse
(
$scope
.
dashboard
.
time
.
from
,
false
);
var
to
=
dateMath
.
parse
(
$scope
.
dashboard
.
time
.
to
,
true
);
if
(
$scope
.
panel
.
timeFrom
)
{
from
=
dateMath
.
parseDateMath
(
'-'
+
$scope
.
panel
.
timeFrom
,
to
,
false
);
}
if
(
$scope
.
panel
.
timeShift
)
{
from
=
dateMath
.
parseDateMath
(
'-'
+
$scope
.
panel
.
timeShift
,
from
,
false
);
to
=
dateMath
.
parseDateMath
(
'-'
+
$scope
.
panel
.
timeShift
,
to
,
true
);
}
var
range
=
Math
.
ceil
((
to
.
valueOf
()
-
from
.
valueOf
())
/
1000
);
var
endTime
=
to
.
format
(
'YYYY-MM-DD HH:MM'
);
var
step
=
kbn
.
interval_to_seconds
(
this
.
target
.
calculatedInterval
);
if
(
step
!==
0
&&
range
/
step
>
11000
)
{
step
=
Math
.
floor
(
range
/
11000
);
}
var
expr
=
{
expr
:
$scope
.
target
.
expr
,
range_input
:
range
+
's'
,
end_input
:
endTime
,
//step_input: step,
step_input
:
''
,
stacked
:
$scope
.
panel
.
stack
,
tab
:
0
};
var
hash
=
encodeURIComponent
(
JSON
.
stringify
([
expr
]));
return
$scope
.
datasource
.
url
+
'/graph#'
+
hash
;
};
$scope
.
calculateInterval
=
function
()
{
var
interval
=
$scope
.
target
.
interval
||
$scope
.
interval
;
var
calculatedInterval
=
$scope
.
datasource
.
calculateInterval
(
interval
,
$scope
.
target
.
intervalFactor
);
$scope
.
target
.
calculatedInterval
=
kbn
.
secondsToHms
(
calculatedInterval
);
};
// TODO: validate target
function
validateTarget
()
{
var
errs
=
{};
return
errs
;
}
$scope
.
init
();
});
});
public/test/specs/prometheus-datasource-specs.js
0 → 100644
View file @
bf98cfea
define
([
'./helpers'
,
'moment'
,
'app/plugins/datasource/prometheus/datasource'
,
'app/services/backendSrv'
,
'app/services/alertSrv'
],
function
(
helpers
,
moment
)
{
'use strict'
;
describe
(
'PrometheusDatasource'
,
function
()
{
var
ctx
=
new
helpers
.
ServiceTestContext
();
beforeEach
(
module
(
'grafana.services'
));
beforeEach
(
ctx
.
providePhase
([
'templateSrv'
]));
beforeEach
(
ctx
.
createService
(
'PrometheusDatasource'
));
beforeEach
(
function
()
{
ctx
.
ds
=
new
ctx
.
service
({
url
:
''
,
user
:
'test'
,
password
:
'mupp'
});
});
describe
(
'When querying prometheus with one target using query editor target spec'
,
function
()
{
var
results
;
var
urlExpected
=
'/api/v1/query_range?query='
+
encodeURIComponent
(
'test{job="testjob"}'
)
+
'&start=1443438675&end=1443460275&step=60'
;
var
query
=
{
range
:
{
from
:
moment
(
1443438674760
),
to
:
moment
(
1443460274760
)
},
targets
:
[{
expr
:
'test{job="testjob"}'
}],
interval
:
'60s'
};
var
response
=
{
"status"
:
"success"
,
"data"
:{
"resultType"
:
"matrix"
,
"result"
:[{
"metric"
:{
"__name__"
:
"test"
,
"job"
:
"testjob"
},
"values"
:[[
1443454528
,
"3846"
]]
}]
}
};
beforeEach
(
function
()
{
ctx
.
$httpBackend
.
expect
(
'GET'
,
urlExpected
).
respond
(
response
);
ctx
.
ds
.
query
(
query
).
then
(
function
(
data
)
{
results
=
data
;
});
ctx
.
$httpBackend
.
flush
();
});
it
(
'should generate the correct query'
,
function
()
{
ctx
.
$httpBackend
.
verifyNoOutstandingExpectation
();
});
it
(
'should return series list'
,
function
()
{
expect
(
results
.
data
.
length
).
to
.
be
(
1
);
expect
(
results
.
data
[
0
].
target
).
to
.
be
(
'test{job="testjob"}'
);
});
});
});
});
tasks/options/requirejs.js
View file @
bf98cfea
...
...
@@ -63,6 +63,7 @@ module.exports = function(config,grunt) {
'app/plugins/datasource/grafana/datasource'
,
'app/plugins/datasource/graphite/datasource'
,
'app/plugins/datasource/influxdb/datasource'
,
'app/plugins/datasource/prometheus/datasource'
,
]
},
];
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment