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
d9d3da4d
Commit
d9d3da4d
authored
Sep 29, 2015
by
Torkel Ödegaard
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'prometheus-datasource' of
https://github.com/jimmidyson/grafana
into prometheus
parents
8a39b32b
bf98cfea
Show 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 @
d9d3da4d
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 @
d9d3da4d
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 @
d9d3da4d
<div
ng-include=
"httpConfigPartialSrc"
></div>
<br>
public/app/plugins/datasource/prometheus/partials/query.editor.html
0 → 100644
View file @
d9d3da4d
<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 @
d9d3da4d
{
"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 @
d9d3da4d
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 @
d9d3da4d
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 @
d9d3da4d
...
...
@@ -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