ruby-grafana-reporter 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +203 -185
  3. data/lib/VERSION.rb +2 -2
  4. data/lib/grafana/abstract_datasource.rb +25 -15
  5. data/lib/grafana/errors.rb +10 -2
  6. data/lib/grafana/grafana.rb +2 -0
  7. data/lib/grafana/grafana_property_datasource.rb +5 -0
  8. data/lib/grafana/graphite_datasource.rb +27 -5
  9. data/lib/grafana/influxdb_datasource.rb +70 -0
  10. data/lib/grafana/prometheus_datasource.rb +27 -5
  11. data/lib/grafana/sql_datasource.rb +9 -2
  12. data/lib/grafana/variable.rb +0 -1
  13. data/lib/grafana_reporter/abstract_query.rb +124 -23
  14. data/lib/grafana_reporter/abstract_report.rb +21 -2
  15. data/lib/grafana_reporter/alerts_table_query.rb +1 -6
  16. data/lib/grafana_reporter/annotations_table_query.rb +1 -6
  17. data/lib/grafana_reporter/application/webservice.rb +1 -1
  18. data/lib/grafana_reporter/asciidoctor/alerts_table_include_processor.rb +5 -4
  19. data/lib/grafana_reporter/asciidoctor/annotations_table_include_processor.rb +5 -4
  20. data/lib/grafana_reporter/asciidoctor/panel_image_block_macro.rb +1 -4
  21. data/lib/grafana_reporter/asciidoctor/panel_image_inline_macro.rb +1 -4
  22. data/lib/grafana_reporter/asciidoctor/panel_property_inline_macro.rb +1 -4
  23. data/lib/grafana_reporter/asciidoctor/panel_query_table_include_processor.rb +1 -5
  24. data/lib/grafana_reporter/asciidoctor/panel_query_value_inline_macro.rb +1 -4
  25. data/lib/grafana_reporter/asciidoctor/processor_mixin.rb +20 -35
  26. data/lib/grafana_reporter/asciidoctor/report.rb +2 -14
  27. data/lib/grafana_reporter/asciidoctor/sql_table_include_processor.rb +1 -3
  28. data/lib/grafana_reporter/asciidoctor/sql_value_inline_macro.rb +1 -3
  29. data/lib/grafana_reporter/demo_report_wizard.rb +5 -1
  30. data/lib/grafana_reporter/erb/report.rb +3 -16
  31. data/lib/grafana_reporter/erb/report_jail.rb +21 -0
  32. data/lib/grafana_reporter/errors.rb +14 -0
  33. data/lib/grafana_reporter/help.rb +2 -2
  34. data/lib/grafana_reporter/panel_image_query.rb +1 -5
  35. data/lib/grafana_reporter/query_value_query.rb +1 -19
  36. data/lib/ruby_grafana_reporter.rb +0 -12
  37. metadata +4 -2
@@ -49,10 +49,10 @@ module Grafana
49
49
  #
50
50
  # Most likely this happens, because the image renderer is not configures properly in grafana,
51
51
  # or the panel rendering ran into a timeout.
52
+ # @param panel [Panel] panel object, which could not be rendered
52
53
  class ImageCouldNotBeRenderedError < GrafanaError
53
- # @param panel [Panel] panel object, which could not be rendered
54
54
  def initialize(panel)
55
- super("The specified panel '#{panel.id}' from dashboard '#{panel.dashboard.id} could not be "\
55
+ super("The specified panel '#{panel.id}' from dashboard '#{panel.dashboard.id}' could not be "\
56
56
  'rendered to an image.')
57
57
  end
58
58
  end
@@ -70,4 +70,12 @@ module Grafana
70
70
  super("The datasource query provided, does not look like a grafana datasource target (received: #{query}).")
71
71
  end
72
72
  end
73
+
74
+ # Raised if a datasource implementation cannot handle a query, which is composed
75
+ # in the grafana visual editor.
76
+ class ComposedQueryNotSupportedError < GrafanaError
77
+ def initialize(class_obj)
78
+ super("Composed queries are not yet supported for datasource '#{class_obj}'.")
79
+ end
80
+ end
73
81
  end
@@ -50,6 +50,8 @@ module Grafana
50
50
  # @return [Datasource] Datasource for the specified datasource name
51
51
  def datasource_by_name(datasource_name)
52
52
  datasource_name = 'default' if datasource_name.to_s.empty?
53
+ # TODO: add support for grafana builtin datasource types
54
+ return UnsupportedDatasource.new(nil) if datasource_name.to_s =~ /-- (?:Mixed|Dashboard|Grafana) --/
53
55
  raise DatasourceDoesNotExistError.new('name', datasource_name) unless @datasources[datasource_name]
54
56
 
55
57
  @datasources[datasource_name]
@@ -21,5 +21,10 @@ module Grafana
21
21
  content: [replace_variables(panel.field(property_name), query_description[:variables])]
22
22
  }
23
23
  end
24
+
25
+ # @see AbstractDatasource#default_variable_format
26
+ def default_variable_format
27
+ 'glob'
28
+ end
24
29
  end
25
30
  end
@@ -36,15 +36,37 @@ module Grafana
36
36
  panel_query_target['target']
37
37
  end
38
38
 
39
+ # @see AbstractDatasource#default_variable_format
40
+ def default_variable_format
41
+ 'glob'
42
+ end
43
+
39
44
  private
40
45
 
41
46
  # @see AbstractDatasource#preformat_response
42
47
  def preformat_response(response_body)
43
- # TODO: support multiple metrics as return types
44
- {
45
- header: %w[value time],
46
- content: JSON.parse(response_body).first['datapoints']
47
- }
48
+ json = JSON.parse(response_body)
49
+
50
+ header = ['time']
51
+ content = {}
52
+
53
+ # keep sorting, if json has only one target item, otherwise merge results and return
54
+ # as a time sorted array
55
+ return { header: header << json.first['target'], content: json.first['datapoints'].map! { |item| [item[1], item[0]] } } if json.length == 1
56
+
57
+ # TODO: show warning if results may be sorted different
58
+ json.each_index do |i|
59
+ header << json[i]['target']
60
+ tmp = json[i]['datapoints'].map! { |item| [item[1], item[0]] }.to_h
61
+ tmp.each_key { |key| content[key] = Array.new(json.length) unless content[key] }
62
+
63
+ content.merge!(tmp) do |_key, old, new|
64
+ old[i] = new
65
+ old
66
+ end
67
+ end
68
+
69
+ { header: header, content: content.to_a.map(&:flatten).sort { |a, b| a[0] <=> b[0] } }
48
70
  end
49
71
  end
50
72
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grafana
4
+ # Implements the interface to Prometheus datasources.
5
+ class InfluxDbDatasource < AbstractDatasource
6
+ # @see AbstractDatasource#handles?
7
+ def self.handles?(model)
8
+ tmp = new(model)
9
+ tmp.type == 'influxdb'
10
+ end
11
+
12
+ # +:database+ needs to contain the InfluxDb database name
13
+ # +:raw_query+ needs to contain a InfluxDb query as String
14
+ # @see AbstractDatasource#request
15
+ def request(query_description)
16
+ raise MissingSqlQueryError if query_description[:raw_query].nil?
17
+
18
+ url = "/api/datasources/proxy/#{id}/query?db=#{@model['database']}&q=#{URI.encode(query_description[:raw_query])}&epoch=ms"
19
+
20
+ webrequest = query_description[:prepared_request]
21
+ webrequest.relative_url = url
22
+ webrequest.options.merge!({ request: Net::HTTP::Get })
23
+
24
+ result = webrequest.execute(query_description[:timeout])
25
+ preformat_response(result.body)
26
+ end
27
+
28
+ # @see AbstractDatasource#raw_query_from_panel_model
29
+ def raw_query_from_panel_model(panel_query_target)
30
+ return panel_query_target['query'] if panel_query_target['rawQuery']
31
+
32
+ # TODO: support composed queries
33
+ raise ComposedQueryNotSupportedError, self
34
+ end
35
+
36
+ # @see AbstractDatasource#default_variable_format
37
+ def default_variable_format
38
+ 'regex'
39
+ end
40
+
41
+ private
42
+
43
+ # @see AbstractDatasource#preformat_response
44
+ def preformat_response(response_body)
45
+ # TODO: how to handle multiple query results?
46
+ json = JSON.parse(response_body)['results'].first['series']
47
+
48
+ header = ['time']
49
+ content = {}
50
+
51
+ # keep sorting, if json has only one target item, otherwise merge results and return
52
+ # as a time sorted array
53
+ return { header: header << "#{json.first['name']} #{json.first['columns'][1]} (#{json.first['tags']})", content: json.first['values'] } if json.length == 1
54
+
55
+ # TODO: show warning here, as results may be sorted different
56
+ json.each_index do |i|
57
+ header << "#{json[i]['name']} #{json[i]['columns'][1]} (#{json[i]['tags']})"
58
+ tmp = json[i]['values'].to_h
59
+ tmp.each_key { |key| content[key] = Array.new(json.length) unless content[key] }
60
+
61
+ content.merge!(tmp) do |_key, old, new|
62
+ old[i] = new
63
+ old
64
+ end
65
+ end
66
+
67
+ { header: header, content: content.to_a.map(&:flatten).sort { |a, b| a[0] <=> b[0] } }
68
+ end
69
+ end
70
+ end
@@ -31,15 +31,37 @@ module Grafana
31
31
  panel_query_target['expr']
32
32
  end
33
33
 
34
+ # @see AbstractDatasource#default_variable_format
35
+ def default_variable_format
36
+ 'regex'
37
+ end
38
+
34
39
  private
35
40
 
36
41
  # @see AbstractDatasource#preformat_response
37
42
  def preformat_response(response_body)
38
- # TODO: support multiple metrics as return types
39
- {
40
- header: %w[time value],
41
- content: JSON.parse(response_body)['data']['result'].first['values']
42
- }
43
+ json = JSON.parse(response_body)['data']['result']
44
+
45
+ headers = ['time']
46
+ content = {}
47
+
48
+ # keep sorting, if json has only one target item, otherwise merge results and return
49
+ # as a time sorted array
50
+ return { header: headers << json.first['metric']['mode'], content: json.first['values'] } if json.length == 1
51
+
52
+ # TODO: show warning if results may be sorted different
53
+ json.each_index do |i|
54
+ headers += [json[i]['metric']['mode']]
55
+ tmp = json[i]['values'].to_h
56
+ tmp.each_key { |key| content[key] = Array.new(json.length) unless content[key] }
57
+
58
+ content.merge!(tmp) do |_key, old, new|
59
+ old[i] = new
60
+ old
61
+ end
62
+ end
63
+
64
+ { header: headers, content: content.to_a.map(&:flatten).sort { |a, b| a[0] <=> b[0] } }
43
65
  end
44
66
  end
45
67
  end
@@ -32,11 +32,18 @@ module Grafana
32
32
  preformat_response(result.body)
33
33
  end
34
34
 
35
+ # Currently all composed SQL queries are saved in the dashboard as rawSql, so no conversion
36
+ # necessary here.
35
37
  # @see AbstractDatasource#raw_query_from_panel_model
36
38
  def raw_query_from_panel_model(panel_query_target)
37
39
  panel_query_target['rawSql']
38
40
  end
39
41
 
42
+ # @see AbstractDatasource#default_variable_format
43
+ def default_variable_format
44
+ 'glob'
45
+ end
46
+
40
47
  private
41
48
 
42
49
  def preformat_response(response_body)
@@ -45,12 +52,12 @@ module Grafana
45
52
 
46
53
  JSON.parse(response_body)['results'].each_value do |query_result|
47
54
  if query_result.key?('error')
48
- results[:header] = results[:header] << ['SQL Error']
55
+ results[:header] = results[:header] + ['SQL Error']
49
56
  results[:content] = [[query_result['error']]]
50
57
 
51
58
  elsif query_result['tables']
52
59
  query_result['tables'].each do |table|
53
- results[:header] = results[:header] << table['columns'].map { |header| header['text'] }
60
+ results[:header] = results[:header] + table['columns'].map { |header| header['text'] }
54
61
  results[:content] = table['rows']
55
62
  end
56
63
 
@@ -156,7 +156,6 @@ module Grafana
156
156
 
157
157
  else
158
158
  # glob and all unknown
159
- # TODO add check for array value properly for all cases
160
159
  return "{#{value.join(',')}}" if multi? && value.is_a?(Array)
161
160
 
162
161
  value
@@ -5,19 +5,51 @@ module GrafanaReporter
5
5
  #
6
6
  # Superclass containing everything for all queries towards grafana.
7
7
  class AbstractQuery
8
- attr_accessor :datasource, :timeout, :from, :to
8
+ attr_accessor :datasource
9
9
  attr_writer :raw_query
10
- attr_reader :variables, :result, :panel
10
+ attr_reader :variables, :result, :panel, :dashboard
11
+
12
+ def timeout
13
+ # TODO: check where value priorities should be evaluated
14
+ return @variables['timeout'].raw_value if @variables['timeout']
15
+ return @variables['grafana_default_timeout'].raw_value if @variables['grafana_default_timeout']
16
+
17
+ nil
18
+ end
19
+
20
+ # @param grafana_obj [Object] {Grafana::Grafana}, {Grafana::Dashboard} or {Grafana::Panel} object for which the query is executed
21
+ # @param opts [Hash] hash options, which may consist of:
22
+ # @option opts [Hash] :variables hash of variables, which shall be used to replace variable references in the query
23
+ # @option opts [Boolean] :ignore_dashboard_defaults True if {#assign_dashboard_defaults} should not be called
24
+ # @option opts [Boolean] :do_not_use_translated_times True if given from and to times should used as is, without being resolved to reporter times - using this parameter can lead to inconsistent report contents
25
+ def initialize(grafana_obj, opts = {})
26
+ if grafana_obj.is_a?(Grafana::Panel)
27
+ @panel = grafana_obj
28
+ @dashboard = @panel.dashboard
29
+ @grafana = @dashboard.grafana
30
+
31
+ elsif grafana_obj.is_a?(Grafana::Dashboard)
32
+ @dashboard = grafana_obj
33
+ @grafana = @dashboard.grafana
34
+
35
+ elsif grafana_obj.is_a?(Grafana::Grafana)
36
+ @grafana = grafana_obj
37
+
38
+ elsif !grafana_obj
39
+ # nil given
11
40
 
12
- # @param grafana_or_panel [Object] {Grafana::Grafana} or {Grafana::Panel} object for which the query is executed
13
- def initialize(grafana_or_panel)
14
- if grafana_or_panel.is_a?(Grafana::Panel)
15
- @panel = grafana_or_panel
16
- @grafana = @panel.dashboard.grafana
17
41
  else
18
- @grafana = grafana_or_panel
42
+ raise GrafanaReporterError, "Internal error in AbstractQuery: given object is of type #{grafana_obj.class.name}, which is not supported"
19
43
  end
20
44
  @variables = {}
45
+ @variables['from'] = Grafana::Variable.new(nil)
46
+ @variables['to'] = Grafana::Variable.new(nil)
47
+
48
+ assign_dashboard_defaults unless opts[:ignore_dashboard_defaults]
49
+ opts[:variables].each { |k, v| assign_variable(k, v) } if opts[:variables].is_a?(Hash)
50
+
51
+ @translate_times = true
52
+ @translate_times = false if opts[:do_not_use_translated_times]
21
53
  end
22
54
 
23
55
  # @abstract
@@ -31,11 +63,32 @@ module GrafanaReporter
31
63
  def execute
32
64
  return @result unless @result.nil?
33
65
 
66
+ from = @variables['from'].raw_value
67
+ to = @variables['to'].raw_value
68
+ if @translate_times
69
+ from = translate_date(@variables['from'], @variables['grafana_report_timestamp'], false, @variables['from_timezone'] ||
70
+ @variables['grafana_default_from_timezone'])
71
+ to = translate_date(@variables['to'], @variables['grafana_report_timestamp'], true, @variables['to_timezone'] ||
72
+ @variables['grafana_default_to_timezone'])
73
+ end
74
+
34
75
  pre_process
35
76
  raise DatasourceNotSupportedError.new(@datasource, self) if @datasource.is_a?(Grafana::UnsupportedDatasource)
36
77
 
37
- @result = @datasource.request(from: @from, to: @to, raw_query: raw_query, variables: grafana_variables,
38
- prepared_request: @grafana.prepare_request, timeout: timeout)
78
+ begin
79
+ @result = @datasource.request(from: from, to: to, raw_query: raw_query, variables: grafana_variables,
80
+ prepared_request: @grafana.prepare_request, timeout: timeout)
81
+ rescue ::Grafana::GrafanaError
82
+ # grafana errors will be directly passed through
83
+ raise
84
+ rescue GrafanaReporterError
85
+ # grafana errors will be directly passed through
86
+ raise
87
+ rescue StandardError => e
88
+ raise DatasourceRequestInternalError.new(@datasource, e.message)
89
+ end
90
+
91
+ raise DatasourceRequestInvalidReturnValueError.new(@datasource, @result) unless datasource_response_valid?
39
92
  post_process
40
93
  @result
41
94
  end
@@ -66,17 +119,6 @@ module GrafanaReporter
66
119
  raise NotImplementedError
67
120
  end
68
121
 
69
- # Used to specify variables to be used for this query. This method ensures, that only the values of the
70
- # {Grafana::Variable} stored in the +variables+ Array are overwritten.
71
- # @param name [String] name of the variable to set
72
- # @param variable [Grafana::Variable] variable from which the {Grafana::Variable#raw_value} will be assigned to the query variables
73
- def assign_variable(name, variable)
74
- raise GrafanaReporterError, "Provided variable is not of type Grafana::Variable (name: '#{name}', value: '#{value}')" unless variable.is_a?(Grafana::Variable)
75
-
76
- @variables[name] ||= variable
77
- @variables[name].raw_value = variable.raw_value
78
- end
79
-
80
122
  # Transposes the given result.
81
123
  #
82
124
  # NOTE: Only the +:content+ of the given result hash is transposed. The +:header+ is ignored.
@@ -105,10 +147,10 @@ module GrafanaReporter
105
147
 
106
148
  filter_columns = filter_columns_variable.raw_value
107
149
  filter_columns.split(',').each do |filter_column|
108
- pos = result[:header][0].index(filter_column)
150
+ pos = result[:header].index(filter_column)
109
151
 
110
152
  unless pos.nil?
111
- result[:header][0].delete_at(pos)
153
+ result[:header].delete_at(pos)
112
154
  result[:content].each { |row| row.delete_at(pos) }
113
155
  end
114
156
  end
@@ -200,6 +242,7 @@ module GrafanaReporter
200
242
  begin
201
243
  row[i] = row[i].to_s.gsub(/#{k}/, v) if row[i].to_s =~ /#{k}/
202
244
  rescue StandardError => e
245
+ @grafana.logger.error(e.message)
203
246
  row[i] = e.message
204
247
  end
205
248
 
@@ -241,6 +284,24 @@ module GrafanaReporter
241
284
  result
242
285
  end
243
286
 
287
+ # Used to build a output format matching the requested report format.
288
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
289
+ # @param opts [Hash] options for the formatting:
290
+ # @option opts [Grafana::Variable] :row_divider requested row divider for the result table
291
+ # @option opts [Grafana::Variable] :column_divider requested row divider for the result table
292
+ # @option opts [Regex or String] :escape_regex regular expression which specifies a part of a cell content, which has to be escaped
293
+ # @option opts [String] :escape_replacement specifies how the found :escape_regex shall be replaced
294
+ # @return [String] formatted table result in requested output format
295
+ def format_table_output(result, opts)
296
+ opts = { escape_regex: '|', escape_replacement: '\\|', row_divider: Grafana::Variable.new('| '), column_divider: Grafana::Variable.new(' | ') }.merge(opts.delete_if {|_k, v| v.nil? })
297
+
298
+ result[:content].map do |row|
299
+ opts[:row_divider].raw_value + row.map do |item|
300
+ item.to_s.gsub(opts[:escape_regex], opts[:escape_replacement])
301
+ end.join(opts[:column_divider].raw_value)
302
+ end
303
+ end
304
+
244
305
  # Used to translate the relative date strings used by grafana, e.g. +now-5d/w+ to the
245
306
  # correct timestamp. Reason is that grafana does this in the frontend, which we have
246
307
  # to emulate here for the reporter.
@@ -258,6 +319,7 @@ module GrafanaReporter
258
319
  def translate_date(orig_date, report_time, is_to_time, timezone = nil)
259
320
  # TODO: add test case for creation of variable, if not given, maybe also print a warning
260
321
  report_time ||= ::Grafana::Variable.new(Time.now.to_s)
322
+ orig_date = orig_date.raw_value if orig_date.is_a?(Grafana::Variable)
261
323
  return (DateTime.parse(report_time.raw_value).to_time.to_i * 1000).to_s unless orig_date
262
324
  return orig_date if orig_date =~ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
263
325
  return orig_date if orig_date =~ /^\d+$/
@@ -296,6 +358,45 @@ module GrafanaReporter
296
358
 
297
359
  private
298
360
 
361
+ # Used to specify variables to be used for this query. This method ensures, that only the values of the
362
+ # {Grafana::Variable} stored in the +variables+ Array are overwritten.
363
+ # @param name [String] name of the variable to set
364
+ # @param variable [Grafana::Variable] variable from which the {Grafana::Variable#raw_value} will be assigned to the query variables
365
+ def assign_variable(name, variable)
366
+ variable = Grafana::Variable.new(variable) unless variable.is_a?(Grafana::Variable)
367
+
368
+ @variables[name] ||= variable
369
+ @variables[name].raw_value = variable.raw_value
370
+ end
371
+
372
+ # Sets default configurations from the given {Grafana::Dashboard} and store them as settings in the
373
+ # {AbstractQuery}.
374
+ #
375
+ # Following data is extracted:
376
+ # - +from+, by {Grafana::Dashboard#from_time}
377
+ # - +to+, by {Grafana::Dashboard#to_time}
378
+ # - and all variables as {Grafana::Variable}, prefixed with +var-+, as grafana also does it
379
+ def assign_dashboard_defaults
380
+ return unless @dashboard
381
+
382
+ assign_variable('from', @dashboard.from_time)
383
+ assign_variable('to', @dashboard.to_time)
384
+ @dashboard.variables.each { |item| assign_variable("var-#{item.name}", item) }
385
+ end
386
+
387
+ def datasource_response_valid?
388
+ return false if @result.nil?
389
+ return false unless @result.is_a?(Hash)
390
+ # TODO: check if it should be ok if a datasource request returns an empty hash only
391
+ return true if @result.empty?
392
+ return false unless @result.has_key?(:header)
393
+ return false unless @result.has_key?(:content)
394
+ return false unless @result[:header].is_a?(Array)
395
+ return false unless @result[:content].is_a?(Array)
396
+
397
+ true
398
+ end
399
+
299
400
  # @return [Hash<String, Variable>] all grafana variables stored in this query, i.e. the variable name
300
401
  # is prefixed with +var-+
301
402
  def grafana_variables