ruby-grafana-reporter 0.6.3 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +48 -10
- data/bin/ruby-grafana-reporter +1 -1
- data/lib/VERSION.rb +2 -2
- data/lib/grafana/grafana.rb +19 -0
- data/lib/grafana/image_rendering_datasource.rb +2 -2
- data/lib/grafana/influxdb_datasource.rb +30 -5
- data/lib/grafana_reporter/abstract_query.rb +1 -1
- data/lib/grafana_reporter/abstract_report.rb +1 -1
- data/lib/grafana_reporter/application/application.rb +2 -2
- data/lib/grafana_reporter/asciidoctor/help.rb +11 -0
- data/lib/grafana_reporter/asciidoctor/panel_query_table_include_processor.rb +0 -1
- data/lib/grafana_reporter/asciidoctor/panel_query_value_inline_macro.rb +0 -1
- data/lib/grafana_reporter/asciidoctor/processor_mixin.rb +1 -1
- data/lib/grafana_reporter/asciidoctor/sql_table_include_processor.rb +2 -3
- data/lib/grafana_reporter/asciidoctor/sql_value_inline_macro.rb +2 -3
- data/lib/grafana_reporter/console_configuration_wizard.rb +1 -1
- data/lib/grafana_reporter/errors.rb +10 -1
- data/lib/grafana_reporter/query_value_query.rb +24 -1
- data/lib/ruby_grafana_reporter.rb +2 -0
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 49639b5da410d46b0ad6cf16a187a3f30389fac610497de09d1e1466a66f22f5
|
4
|
+
data.tar.gz: 673791b6d38cd46e54292903f07e0fdd16070f5e0abf999565fecf04b930efbb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d4bb03253d7681c580c96f11e5e86112e036087b927ff01e0e490bebeb4aeadd5f213f9de7202007523b8bf384ed36f6fcc040d5b81aa5aceef88c7cb191ab50
|
7
|
+
data.tar.gz: d3695d4d6ce451c01e7ad33a28a942f697d019215cca603827bda44af488e292c4befaf658d266e19b5ca3e84ab1a06cbc5f4f5e189d262b5c8bb415c4ae1013
|
data/README.md
CHANGED
@@ -20,7 +20,20 @@ Reporting Service for Grafana
|
|
20
20
|
* [Using webhooks](#using-webhooks)
|
21
21
|
* [Developing your own plugin](#developing-your-own-plugin)
|
22
22
|
* [Roadmap](#roadmap)
|
23
|
-
* [
|
23
|
+
* [Contributing](#contributing)
|
24
|
+
* [Licensing](#licensing)
|
25
|
+
* [Acknowledgements](#acknowledgements)
|
26
|
+
|
27
|
+
## Your support is appreciated!
|
28
|
+
|
29
|
+
Hey there! I provide you this software free of charge. I have already
|
30
|
+
spend a lot of my private time in developing, maintaining and supporting it.
|
31
|
+
|
32
|
+
If you enjoy my work, feel free to
|
33
|
+
|
34
|
+
[![buymeacoffee](https://az743702.vo.msecnd.net/cdn/kofi3.png?v=0)](https://ko-fi.com/divinity666)
|
35
|
+
|
36
|
+
Thanks for your support and keeping this project alive!
|
24
37
|
|
25
38
|
## About the project
|
26
39
|
|
@@ -35,6 +48,10 @@ dashboards and to use it in your custom templates to finally create reports in P
|
|
35
48
|
|
36
49
|
By default (an extended version of) Asciidoctor is enabled as template language.
|
37
50
|
|
51
|
+
## Getting started
|
52
|
+
|
53
|
+
![GettingStarted](./assets/GettingStartedAnimation.gif)
|
54
|
+
|
38
55
|
## Features
|
39
56
|
|
40
57
|
* Supports creation of reports for multiple [grafana](https://github.com/grafana/grafana)
|
@@ -136,8 +153,10 @@ asciidoctor:
|
|
136
153
|
|
137
154
|
### Grafana integration
|
138
155
|
|
139
|
-
For using the reporter directly from grafana,
|
140
|
-
|
156
|
+
For using the reporter directly from grafana, the reporter has to run as webservice, i.e. it has to be
|
157
|
+
called without the `-t` parameter.
|
158
|
+
|
159
|
+
If this is the case, you simply simply need add a link to your grafana dashboard:
|
141
160
|
|
142
161
|
* Open the dashboard configuration
|
143
162
|
* Select `Links`
|
@@ -168,7 +187,32 @@ a variable and forward it to the reporter.
|
|
168
187
|
|
169
188
|
## Advanced information
|
170
189
|
|
171
|
-
###
|
190
|
+
### Use grafana variables in templates
|
191
|
+
It is common practice to use dashboard variables in grafana, to allow users to show
|
192
|
+
the dashboard for a specific set of data only. This is where grafana variables are
|
193
|
+
used.
|
194
|
+
|
195
|
+
Those variables are then also used in panel queries, to react on selecting or entering
|
196
|
+
those variables.
|
197
|
+
|
198
|
+
You may provide those variables during report generation to the reporter. Therefore
|
199
|
+
you have to specify them in the individual calls.
|
200
|
+
|
201
|
+
Let's say, you have a variable called `serverid` in the dashboard. You may now want
|
202
|
+
to set this variable for a panel image rendering. This cann be done with the following
|
203
|
+
calls:
|
204
|
+
|
205
|
+
````
|
206
|
+
grafana_panel_image:1[var-serverid=main-server]
|
207
|
+
grafana_panel_image:1[var-serverid=replica-server]
|
208
|
+
````
|
209
|
+
|
210
|
+
This will render two images: one for `main-server` and one for `replica-server`.
|
211
|
+
|
212
|
+
So, to forward grafana variables to the reporter calls, you simply have to use the
|
213
|
+
form `var-<<your-variable-name>>` and specify those in your reporter template.
|
214
|
+
|
215
|
+
### Webservice endpoints
|
172
216
|
|
173
217
|
Running the reporter as a webservice provides the following URLs
|
174
218
|
|
@@ -329,9 +373,3 @@ The code in this project is licensed under MIT license.
|
|
329
373
|
* [grafana](https://github.com/grafana/grafana)
|
330
374
|
|
331
375
|
Inspired by [Izak Marai's grafana reporter](https://github.com/IzakMarais/reporter)
|
332
|
-
|
333
|
-
## Donations
|
334
|
-
|
335
|
-
If you like this project and you would like to support my work, feel free to donate. :)
|
336
|
-
|
337
|
-
[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=35LH6JNLPHPHQ)
|
data/bin/ruby-grafana-reporter
CHANGED
data/lib/VERSION.rb
CHANGED
data/lib/grafana/grafana.rb
CHANGED
@@ -68,6 +68,23 @@ module Grafana
|
|
68
68
|
'NON-Admin'
|
69
69
|
end
|
70
70
|
|
71
|
+
# Returns the datasource, which has been queried by model entry in the panel model.
|
72
|
+
#
|
73
|
+
# @param model_entry [Object] model entry of the searched datasource (e.g. String or Hash)
|
74
|
+
# @return [Datasource] Datasource for the specified datasource model entry
|
75
|
+
def datasource_by_model_entry(model_entry)
|
76
|
+
datasource = nil
|
77
|
+
if model_entry.is_a?(String)
|
78
|
+
datasource = datasource_by_name(model_entry)
|
79
|
+
elsif model_entry.is_a?(Hash)
|
80
|
+
datasource = datasource_by_uid(model_entry['uid'])
|
81
|
+
end
|
82
|
+
|
83
|
+
raise DatasourceDoesNotExistError.new('model entry', model_entry) unless datasource
|
84
|
+
|
85
|
+
datasource
|
86
|
+
end
|
87
|
+
|
71
88
|
# Returns the datasource, which has been queried by the datasource name.
|
72
89
|
#
|
73
90
|
# @param datasource_name [String] name of the searched datasource
|
@@ -86,6 +103,8 @@ module Grafana
|
|
86
103
|
# @param datasource_uid [String] unique id of the searched datasource
|
87
104
|
# @return [Datasource] Datasource for the specified datasource unique id
|
88
105
|
def datasource_by_uid(datasource_uid)
|
106
|
+
raise DatasourceDoesNotExistError.new('uid', datasource_uid) unless datasource_uid
|
107
|
+
|
89
108
|
clean_nil_datasources
|
90
109
|
datasource = @datasources.select { |ds_name, ds| ds.uid == datasource_uid }.values.first
|
91
110
|
raise DatasourceDoesNotExistError.new('uid', datasource_uid) unless datasource
|
@@ -16,7 +16,7 @@ module Grafana
|
|
16
16
|
webrequest.relative_url = panel.render_url + url_params(query_description)
|
17
17
|
webrequest.options.merge!({ accept: 'image/png' })
|
18
18
|
|
19
|
-
result = webrequest.execute
|
19
|
+
result = webrequest.execute(query_description[:timeout])
|
20
20
|
|
21
21
|
raise ImageCouldNotBeRenderedError, panel if result.body.include?('<html')
|
22
22
|
|
@@ -26,7 +26,7 @@ module Grafana
|
|
26
26
|
private
|
27
27
|
|
28
28
|
def url_params(query_desc)
|
29
|
-
url_vars = query_desc[:variables].select { |k, _v| k =~ /^(?:timeout|height|width|theme|fullscreen|var-.+)$/ }
|
29
|
+
url_vars = query_desc[:variables].select { |k, _v| k =~ /^(?:timeout|scale|height|width|theme|fullscreen|var-.+)$/ }
|
30
30
|
url_vars = default_vars.merge(url_vars)
|
31
31
|
url_vars['from'] = Variable.new(query_desc[:from])
|
32
32
|
url_vars['to'] = Variable.new(query_desc[:to])
|
@@ -31,11 +31,36 @@ module Grafana
|
|
31
31
|
query = query.gsub(/\$(?:__)?interval(?=\W|$)/, "#{interval.is_a?(String) ? interval : "#{(interval / 1000).to_i}s"}")
|
32
32
|
query = query.gsub(/\$(?:__)?interval_ms(?=\W|$)/, "#{interval}")
|
33
33
|
|
34
|
-
url = "/api/datasources/proxy/#{id}/query?db=#{@model['database']}&q=#{ERB::Util.url_encode(query)}&epoch=ms"
|
35
|
-
|
36
34
|
webrequest = query_description[:prepared_request]
|
37
|
-
|
38
|
-
|
35
|
+
request = {}
|
36
|
+
|
37
|
+
ver = query_description[:grafana_version].split('.').map{|x| x.to_i}
|
38
|
+
if ver[0] >= 8
|
39
|
+
webrequest.relative_url = "/api/ds/query?ds_type=influxdb"
|
40
|
+
|
41
|
+
request = {
|
42
|
+
request: Net::HTTP::Post,
|
43
|
+
body: {
|
44
|
+
from: query_description[:from],
|
45
|
+
to: query_description[:to],
|
46
|
+
queries: [
|
47
|
+
{
|
48
|
+
datasource: {type: "influxdb"},
|
49
|
+
datasourceId: id,
|
50
|
+
intervalMs: interval,
|
51
|
+
query: query
|
52
|
+
}
|
53
|
+
]}.to_json
|
54
|
+
}
|
55
|
+
else
|
56
|
+
webrequest.relative_url = "/api/datasources/proxy/#{id}/query?db=#{@model['database']}&q=#{ERB::Util.url_encode(query)}&epoch=ms"
|
57
|
+
request = {
|
58
|
+
request: Net::HTTP::Get
|
59
|
+
}
|
60
|
+
end
|
61
|
+
|
62
|
+
webrequest.options.merge!(request)
|
63
|
+
|
39
64
|
|
40
65
|
result = webrequest.execute(query_description[:timeout])
|
41
66
|
preformat_response(result.body)
|
@@ -43,7 +68,7 @@ module Grafana
|
|
43
68
|
|
44
69
|
# @see AbstractDatasource#raw_query_from_panel_model
|
45
70
|
def raw_query_from_panel_model(panel_query_target)
|
46
|
-
return panel_query_target['query'] if panel_query_target['rawQuery']
|
71
|
+
return panel_query_target['query'] if panel_query_target['query'] or panel_query_target['rawQuery']
|
47
72
|
|
48
73
|
# build composed queries
|
49
74
|
build_select(panel_query_target['select']) + build_from(panel_query_target) + build_where(panel_query_target['tags']) + build_group_by(panel_query_target['groupBy'])
|
@@ -383,7 +383,7 @@ module GrafanaReporter
|
|
383
383
|
delta_match = date_spec.match(/^(?<op>(?:-|\+))(?<count>\d+)?(?<unit>[smhdwMy])/)
|
384
384
|
if delta_match
|
385
385
|
date = delta_date(date, "#{delta_match[:op]}#{delta_match[:count] || 1}".to_i, delta_match[:unit])
|
386
|
-
date_spec = date_spec.gsub(/^#{delta_match[:op]}#{delta_match[:count]}#{delta_match[:unit]}/, '')
|
386
|
+
date_spec = date_spec.gsub(/^#{delta_match[:op] == '+' ? '\+' : '-'}#{delta_match[:count]}#{delta_match[:unit]}/, '')
|
387
387
|
end
|
388
388
|
|
389
389
|
raise TimeRangeUnknownError, orig_date unless fit_match || delta_match
|
@@ -138,7 +138,7 @@ module GrafanaReporter
|
|
138
138
|
|
139
139
|
# automatically add extension, if a file with default template extension exists
|
140
140
|
@template = "#{@template}.#{self.class.default_template_extension}" if File.file?("#{@template}.#{self.class.default_template_extension}") && !File.file?(@template.to_s)
|
141
|
-
raise MissingTemplateError, @template.
|
141
|
+
raise MissingTemplateError, "#{@template}.#{self.class.default_template_extension}" unless File.file?(@template.to_s)
|
142
142
|
|
143
143
|
notify(:on_before_create)
|
144
144
|
@start_time = Time.new
|
@@ -34,8 +34,8 @@ module GrafanaReporter
|
|
34
34
|
action_wizard = false
|
35
35
|
|
36
36
|
parser = OptionParser.new do |opts|
|
37
|
-
opts.banner = if ENV['
|
38
|
-
"Usage: #{ENV['
|
37
|
+
opts.banner = if ENV['OCRAN_EXECUTABLE']
|
38
|
+
"Usage: #{ENV['OCRAN_EXECUTABLE'].gsub("#{Dir.pwd}/".gsub('/', '\\'), '')} [options]"
|
39
39
|
else
|
40
40
|
"Usage: #{Gem.ruby} #{$PROGRAM_NAME} [options]"
|
41
41
|
end
|
@@ -229,6 +229,12 @@ end}
|
|
229
229
|
escaped by using `_,`. Execution of related functions is applied in the following order
|
230
230
|
`format`, `replace_values`, `filter_columns`, `transpose`.
|
231
231
|
|
232
|
+
select_value:
|
233
|
+
call: select_value="<select_value>"
|
234
|
+
description: >-
|
235
|
+
Allows the selection of a specific value from the result set. Supported options are `min`, `max`, `avg`,
|
236
|
+
`sum`, `first`, `last`.
|
237
|
+
|
232
238
|
transpose:
|
233
239
|
call: transpose="true"
|
234
240
|
description: >-
|
@@ -391,6 +397,9 @@ end}
|
|
391
397
|
render-width:
|
392
398
|
description: can be used to override default `width` in which the panel shall be rendered
|
393
399
|
call: render-width="<width>"
|
400
|
+
render-scale:
|
401
|
+
description: can be used to override default scale in which the panel shall be rendered
|
402
|
+
call: render-scale="<scale>"
|
394
403
|
render-theme:
|
395
404
|
description: can be used to override default `theme` in which the panel shall be rendered (light by default)
|
396
405
|
call: render-theme="<theme>"
|
@@ -453,6 +462,7 @@ end}
|
|
453
462
|
from:
|
454
463
|
instance:
|
455
464
|
replace_values:
|
465
|
+
select_value:
|
456
466
|
timeout:
|
457
467
|
to:
|
458
468
|
from_timezone:
|
@@ -502,6 +512,7 @@ end}
|
|
502
512
|
from:
|
503
513
|
instance:
|
504
514
|
replace_values:
|
515
|
+
select_value:
|
505
516
|
timeout:
|
506
517
|
to:
|
507
518
|
from_timezone:
|
@@ -40,7 +40,7 @@ module GrafanaReporter
|
|
40
40
|
k =~ /^(?:timeout|from|to)$/ ||
|
41
41
|
k =~ /filter_columns|format|replace_values_.*|transpose|from_timezone|
|
42
42
|
to_timezone|result_type|query|table_formatter|include_headline|
|
43
|
-
column_divider|row_divider|instant|interval|verbose_log/x
|
43
|
+
column_divider|row_divider|instant|interval|verbose_log|select_value/x
|
44
44
|
end)
|
45
45
|
|
46
46
|
result
|
@@ -74,7 +74,6 @@ module GrafanaReporter
|
|
74
74
|
# @see ProcessorMixin#build_demo_entry
|
75
75
|
def build_demo_entry(panel)
|
76
76
|
return nil unless panel
|
77
|
-
return nil unless panel.model['type'].include?('table')
|
78
77
|
|
79
78
|
ref_id = nil
|
80
79
|
panel.model['targets'].each do |item|
|
@@ -85,8 +84,8 @@ module GrafanaReporter
|
|
85
84
|
end
|
86
85
|
return nil unless ref_id
|
87
86
|
|
88
|
-
"|===\ninclude::grafana_sql_table:#{panel.dashboard.grafana.
|
89
|
-
"[sql=\"#{panel.query(ref_id).gsub(/"/, '\"').gsub("\n", ' ').gsub(/\\/, '\\\\')}\",filter_columns=\"time\","\
|
87
|
+
"|===\ninclude::grafana_sql_table:#{panel.dashboard.grafana.datasource_by_model_entry(panel.model['datasource']).id}"\
|
88
|
+
"[sql=\"#{panel.query(ref_id).gsub(/"/, '\"').gsub("\r\n", ' ').gsub("\n", ' ').gsub(/\\/, '\\\\')}\",filter_columns=\"time\","\
|
90
89
|
"dashboard=\"#{panel.dashboard.id}\",from=\"now-1h\",to=\"now\"]\n|==="
|
91
90
|
end
|
92
91
|
end
|
@@ -79,7 +79,6 @@ module GrafanaReporter
|
|
79
79
|
# @see ProcessorMixin#build_demo_entry
|
80
80
|
def build_demo_entry(panel)
|
81
81
|
return nil unless panel
|
82
|
-
return nil unless panel.model['type'] == 'singlestat'
|
83
82
|
|
84
83
|
ref_id = nil
|
85
84
|
panel.model['targets'].each do |item|
|
@@ -90,8 +89,8 @@ module GrafanaReporter
|
|
90
89
|
end
|
91
90
|
return nil unless ref_id
|
92
91
|
|
93
|
-
"grafana_sql_value:#{panel.dashboard.grafana.
|
94
|
-
"[sql=\"#{panel.query(ref_id).gsub(/"/, '\"').gsub("\n", ' ').gsub(/\\/, '\\\\')}\",from=\"now-1h\","\
|
92
|
+
"grafana_sql_value:#{panel.dashboard.grafana.datasource_by_model_entry(panel.model['datasource']).id}"\
|
93
|
+
"[sql=\"#{panel.query(ref_id).gsub(/"/, '\"').gsub("\r\n", ' ').gsub("\n", ' ').gsub(/\\/, '\\\\')}\",from=\"now-1h\","\
|
95
94
|
'to="now"]'
|
96
95
|
end
|
97
96
|
end
|
@@ -32,7 +32,7 @@ module GrafanaReporter
|
|
32
32
|
demo_report ||= '<<your_report_name>>'
|
33
33
|
config_param = config_file == Configuration::DEFAULT_CONFIG_FILE_NAME ? '' : " -c #{config_file}"
|
34
34
|
program_call = "#{Gem.ruby} #{$PROGRAM_NAME}"
|
35
|
-
program_call = ENV['
|
35
|
+
program_call = ENV['OCRAN_EXECUTABLE'].gsub("#{Dir.pwd}/".gsub('/', '\\'), '') if ENV['OCRAN_EXECUTABLE']
|
36
36
|
|
37
37
|
puts
|
38
38
|
puts 'Now everything is setup properly. Create your reports as required in the templates '\
|
@@ -50,7 +50,7 @@ module GrafanaReporter
|
|
50
50
|
# Thrown if a non existing template has been specified.
|
51
51
|
class MissingTemplateError < ConfigurationError
|
52
52
|
def initialize(template)
|
53
|
-
super("
|
53
|
+
super("Accessing report template file '#{template}' is not possible. Check if file exists and is accessible.")
|
54
54
|
end
|
55
55
|
end
|
56
56
|
|
@@ -79,6 +79,15 @@ module GrafanaReporter
|
|
79
79
|
end
|
80
80
|
end
|
81
81
|
|
82
|
+
# Thrown, if the value configuration in {QueryValueQuery#select_value} is
|
83
|
+
# invalid.
|
84
|
+
class UnsupportedSelectValueStatementError < GrafanaReporterError
|
85
|
+
def initialize(statement)
|
86
|
+
super("Unsupported 'select_value' specified in template file: '#{statement}'. Supported values are 'min', 'max', "\
|
87
|
+
"'avg', 'sum', 'first', 'last'.")
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
82
91
|
# Thrown, if a configured parameter is malformed.
|
83
92
|
class MalformedAttributeContentError < GrafanaReporterError
|
84
93
|
def initialize(message, attribute, content)
|
@@ -33,7 +33,30 @@ module GrafanaReporter
|
|
33
33
|
|
34
34
|
when /(?:panel_value|sql_value)/
|
35
35
|
tmp = @result[:content] || []
|
36
|
-
|
36
|
+
# use only first column of return values and replace null values with zero
|
37
|
+
tmp = tmp.map{ |item| item[0] || 0 }
|
38
|
+
|
39
|
+
# as default behaviour we fallback to the first_value, as this was the default in older releases
|
40
|
+
select_value = 'first'
|
41
|
+
select_value = @variables['select_value'].raw_value if @variables['select_value']
|
42
|
+
case select_value
|
43
|
+
when 'min'
|
44
|
+
result = tmp.min
|
45
|
+
when 'max'
|
46
|
+
result = tmp.max
|
47
|
+
when 'avg'
|
48
|
+
result = tmp.size > 0 ? tmp.sum / tmp.size : 0
|
49
|
+
when 'sum'
|
50
|
+
result = tmp.sum
|
51
|
+
when 'last'
|
52
|
+
result = tmp.last
|
53
|
+
when 'first'
|
54
|
+
result = tmp.first
|
55
|
+
else
|
56
|
+
raise UnsupportedSelectValueStatementError, @variables['select_value'].raw_value
|
57
|
+
end
|
58
|
+
|
59
|
+
@result = result
|
37
60
|
|
38
61
|
else
|
39
62
|
raise StandardError, "Unsupported 'result_type' received: '#{@variables['result_type'].raw_value}'"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby-grafana-reporter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Christian Kohlmeyer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-05-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: asciidoctor
|
@@ -206,7 +206,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
206
206
|
- !ruby/object:Gem::Version
|
207
207
|
version: '0'
|
208
208
|
requirements: []
|
209
|
-
rubygems_version: 3.1.
|
209
|
+
rubygems_version: 3.1.6
|
210
210
|
signing_key:
|
211
211
|
specification_version: 4
|
212
212
|
summary: Reporter Service for Grafana
|