ruby-grafana-reporter 0.2.2 → 0.4.3

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +126 -88
  3. data/bin/ruby-grafana-reporter +2 -2
  4. data/lib/VERSION.rb +3 -2
  5. data/lib/grafana/abstract_datasource.rb +146 -0
  6. data/lib/grafana/dashboard.rb +1 -3
  7. data/lib/grafana/errors.rb +18 -3
  8. data/lib/grafana/grafana.rb +64 -66
  9. data/lib/grafana/grafana_alerts_datasource.rb +57 -0
  10. data/lib/grafana/grafana_annotations_datasource.rb +56 -0
  11. data/lib/grafana/grafana_property_datasource.rb +30 -0
  12. data/lib/grafana/graphite_datasource.rb +72 -0
  13. data/lib/grafana/image_rendering_datasource.rb +44 -0
  14. data/lib/grafana/influxdb_datasource.rb +70 -0
  15. data/lib/grafana/panel.rb +9 -3
  16. data/lib/grafana/prometheus_datasource.rb +67 -0
  17. data/lib/grafana/sql_datasource.rb +78 -0
  18. data/lib/grafana/unsupported_datasource.rb +7 -0
  19. data/lib/grafana/variable.rb +1 -1
  20. data/lib/grafana/webrequest.rb +71 -0
  21. data/lib/grafana_reporter/abstract_query.rb +460 -0
  22. data/lib/grafana_reporter/abstract_report.rb +139 -18
  23. data/lib/grafana_reporter/alerts_table_query.rb +39 -0
  24. data/lib/grafana_reporter/annotations_table_query.rb +38 -0
  25. data/lib/grafana_reporter/application/application.rb +34 -286
  26. data/lib/grafana_reporter/application/webservice.rb +50 -15
  27. data/lib/grafana_reporter/asciidoctor/alerts_table_include_processor.rb +91 -0
  28. data/lib/grafana_reporter/asciidoctor/annotations_table_include_processor.rb +90 -0
  29. data/lib/grafana_reporter/asciidoctor/panel_image_block_macro.rb +74 -0
  30. data/lib/grafana_reporter/asciidoctor/panel_image_inline_macro.rb +76 -0
  31. data/lib/grafana_reporter/asciidoctor/panel_property_inline_macro.rb +70 -0
  32. data/lib/grafana_reporter/asciidoctor/panel_query_table_include_processor.rb +95 -0
  33. data/lib/grafana_reporter/asciidoctor/panel_query_value_inline_macro.rb +90 -0
  34. data/lib/grafana_reporter/asciidoctor/processor_mixin.rb +49 -0
  35. data/lib/grafana_reporter/asciidoctor/report.rb +32 -76
  36. data/lib/grafana_reporter/asciidoctor/show_environment_include_processor.rb +46 -0
  37. data/lib/grafana_reporter/asciidoctor/show_help_include_processor.rb +35 -0
  38. data/lib/grafana_reporter/asciidoctor/sql_table_include_processor.rb +90 -0
  39. data/lib/grafana_reporter/asciidoctor/sql_value_inline_macro.rb +86 -0
  40. data/lib/grafana_reporter/asciidoctor/value_as_variable_include_processor.rb +90 -0
  41. data/lib/grafana_reporter/configuration.rb +59 -52
  42. data/lib/grafana_reporter/console_configuration_wizard.rb +311 -0
  43. data/lib/grafana_reporter/demo_report_wizard.rb +105 -0
  44. data/lib/grafana_reporter/erb/report.rb +30 -0
  45. data/lib/grafana_reporter/erb/report_jail.rb +21 -0
  46. data/lib/grafana_reporter/errors.rb +55 -0
  47. data/lib/grafana_reporter/help.rb +443 -0
  48. data/lib/grafana_reporter/logger/{two_way_logger.rb → two_way_delegate_logger.rb} +1 -1
  49. data/lib/grafana_reporter/panel_image_query.rb +25 -0
  50. data/lib/grafana_reporter/panel_property_query.rb +22 -0
  51. data/lib/grafana_reporter/query_value_query.rb +61 -0
  52. data/lib/grafana_reporter/report_webhook.rb +35 -0
  53. data/lib/ruby_grafana_extension.rb +8 -0
  54. data/lib/{ruby-grafana-reporter.rb → ruby_grafana_reporter.rb} +1 -0
  55. metadata +47 -39
  56. data/lib/grafana/abstract_panel_query.rb +0 -22
  57. data/lib/grafana/abstract_query.rb +0 -132
  58. data/lib/grafana/abstract_sql_query.rb +0 -51
  59. data/lib/grafana/panel_image_query.rb +0 -52
  60. data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +0 -104
  61. data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +0 -99
  62. data/lib/grafana_reporter/asciidoctor/errors.rb +0 -40
  63. data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +0 -92
  64. data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +0 -91
  65. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +0 -69
  66. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +0 -68
  67. data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +0 -61
  68. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +0 -78
  69. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +0 -73
  70. data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +0 -20
  71. data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +0 -43
  72. data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +0 -30
  73. data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +0 -70
  74. data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +0 -66
  75. data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +0 -86
  76. data/lib/grafana_reporter/asciidoctor/help.rb +0 -435
  77. data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +0 -34
  78. data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +0 -26
  79. data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +0 -44
  80. data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +0 -38
  81. data/lib/grafana_reporter/asciidoctor/query_mixin.rb +0 -301
  82. data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +0 -42
  83. data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +0 -44
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grafana
4
+ # Implements the interface to image rendering datasources.
5
+ class ImageRenderingDatasource < AbstractDatasource
6
+ # +:raw_query+ needs to contain a Hash with the following structure:
7
+ #
8
+ # {
9
+ # panel: {Panel} which shall be rendered
10
+ # }
11
+ # @see AbstractDatasource#request
12
+ def request(query_description)
13
+ webrequest = query_description[:prepared_request]
14
+ webrequest.relative_url = query_description[:raw_query][:panel].render_url + url_params(query_description)
15
+ webrequest.options.merge!({ accept: 'image/png' })
16
+
17
+ result = webrequest.execute
18
+
19
+ { header: ['image'], content: [result.body] }
20
+ end
21
+
22
+ private
23
+
24
+ def url_params(query_desc)
25
+ url_vars = query_desc[:variables].select { |k, _v| k =~ /^(?:timeout|height|width|theme|fullscreen|var-.+)$/ }
26
+ url_vars = default_vars.merge(url_vars)
27
+ url_vars['from'] = Variable.new(query_desc[:from])
28
+ url_vars['to'] = Variable.new(query_desc[:to])
29
+ result = URI.encode_www_form(url_vars.map { |k, v| [k, v.raw_value.to_s] })
30
+
31
+ return '' if result.empty?
32
+
33
+ "&#{result}"
34
+ end
35
+
36
+ def default_vars
37
+ {
38
+ 'fullscreen' => Variable.new(true),
39
+ 'theme' => Variable.new('light'),
40
+ 'timeout' => Variable.new(60)
41
+ }
42
+ end
43
+ end
44
+ 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=#{ERB::Util.url_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
data/lib/grafana/panel.rb CHANGED
@@ -5,6 +5,7 @@ module Grafana
5
5
  class Panel
6
6
  # @return [Dashboard] parent {Dashboard} object
7
7
  attr_reader :dashboard
8
+ attr_reader :model
8
9
 
9
10
  # @param model [Hash] converted JSON Hash of the panel
10
11
  # @param dashboard [Dashboard] parent {Dashboard} object
@@ -25,12 +26,17 @@ module Grafana
25
26
  @model['id']
26
27
  end
27
28
 
28
- # @return [String] SQL query string for the requested query letter
29
+ # @return [Datasource] datasource object specified for the current panel
30
+ def datasource
31
+ dashboard.grafana.datasource_by_name(@model['datasource'])
32
+ end
33
+
34
+ # @return [String] query string for the requested query letter
29
35
  def query(query_letter)
30
36
  query_item = @model['targets'].select { |item| item['refId'].to_s == query_letter.to_s }.first
31
- raise QueryLetterDoesNotExistError.new(query_letter, self) if query_item.nil?
37
+ raise QueryLetterDoesNotExistError.new(query_letter, self) unless query_item
32
38
 
33
- query_item['rawSql']
39
+ datasource.raw_query_from_panel_model(query_item)
34
40
  end
35
41
 
36
42
  # @return [String] relative rendering URL for the panel, to create an image out of it
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grafana
4
+ # Implements the interface to Prometheus datasources.
5
+ class PrometheusDatasource < AbstractDatasource
6
+ # @see AbstractDatasource#handles?
7
+ def self.handles?(model)
8
+ tmp = new(model)
9
+ tmp.type == 'prometheus'
10
+ end
11
+
12
+ # +:raw_query+ needs to contain a Prometheus query as String
13
+ # @see AbstractDatasource#request
14
+ def request(query_description)
15
+ raise MissingSqlQueryError if query_description[:raw_query].nil?
16
+
17
+ url = "/api/datasources/proxy/#{id}/api/v1/query_range?"\
18
+ "start=#{query_description[:from]}&end=#{query_description[:to]}"\
19
+ "&query=#{replace_variables(query_description[:raw_query], query_description[:variables])}"
20
+
21
+ webrequest = query_description[:prepared_request]
22
+ webrequest.relative_url = url
23
+ webrequest.options.merge!({ request: Net::HTTP::Get })
24
+
25
+ result = webrequest.execute(query_description[:timeout])
26
+ preformat_response(result.body)
27
+ end
28
+
29
+ # @see AbstractDatasource#raw_query_from_panel_model
30
+ def raw_query_from_panel_model(panel_query_target)
31
+ panel_query_target['expr']
32
+ end
33
+
34
+ # @see AbstractDatasource#default_variable_format
35
+ def default_variable_format
36
+ 'regex'
37
+ end
38
+
39
+ private
40
+
41
+ # @see AbstractDatasource#preformat_response
42
+ def preformat_response(response_body)
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] } }
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grafana
4
+ # Implements the interface to all SQL based datasources (tested with PostgreSQL and MariaDB/MySQL).
5
+ class SqlDatasource < AbstractDatasource
6
+ # @see AbstractDatasource#handles?
7
+ def self.handles?(model)
8
+ tmp = new(model)
9
+ tmp.category == 'sql'
10
+ end
11
+
12
+ # +:raw_query+ needs to contain a SQL query as String in the respective database dialect
13
+ # @see AbstractDatasource#request
14
+ def request(query_description)
15
+ raise MissingSqlQueryError if query_description[:raw_query].nil?
16
+
17
+ sql = replace_variables(query_description[:raw_query], query_description[:variables])
18
+ request = {
19
+ body: {
20
+ from: query_description[:from],
21
+ to: query_description[:to],
22
+ queries: [rawSql: prepare_sql(sql), datasourceId: id, format: 'table']
23
+ }.to_json,
24
+ request: Net::HTTP::Post
25
+ }
26
+
27
+ webrequest = query_description[:prepared_request]
28
+ webrequest.relative_url = '/api/tsdb/query'
29
+ webrequest.options.merge!(request)
30
+
31
+ result = webrequest.execute(query_description[:timeout])
32
+ preformat_response(result.body)
33
+ end
34
+
35
+ # Currently all composed SQL queries are saved in the dashboard as rawSql, so no conversion
36
+ # necessary here.
37
+ # @see AbstractDatasource#raw_query_from_panel_model
38
+ def raw_query_from_panel_model(panel_query_target)
39
+ panel_query_target['rawSql']
40
+ end
41
+
42
+ # @see AbstractDatasource#default_variable_format
43
+ def default_variable_format
44
+ 'glob'
45
+ end
46
+
47
+ private
48
+
49
+ def preformat_response(response_body)
50
+ results = {}
51
+ results.default = []
52
+
53
+ JSON.parse(response_body)['results'].each_value do |query_result|
54
+ if query_result.key?('error')
55
+ results[:header] = results[:header] + ['SQL Error']
56
+ results[:content] = [[query_result['error']]]
57
+
58
+ elsif query_result['tables']
59
+ query_result['tables'].each do |table|
60
+ results[:header] = results[:header] + table['columns'].map { |header| header['text'] }
61
+ results[:content] = table['rows']
62
+ end
63
+
64
+ end
65
+ end
66
+
67
+ results
68
+ end
69
+
70
+ def prepare_sql(sql)
71
+ # remove comments in query
72
+ sql.gsub!(/--[^\r\n]*(?:[\r\n]+|$)/, ' ')
73
+ sql.gsub!(/\r\n/, ' ')
74
+ sql.gsub!(/\n/, ' ')
75
+ sql
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grafana
4
+ # Dummy class, which is used, if a datasource is currently unsupported.
5
+ class UnsupportedDatasource < AbstractDatasource
6
+ end
7
+ end
@@ -10,6 +10,7 @@ module Grafana
10
10
  class Variable
11
11
  attr_reader :name, :text, :raw_value
12
12
 
13
+ # Translation table to support {https://momentjs.com/docs/#/displaying/}.
13
14
  DATE_MATCHES = { 'M' => '%-m', 'MM' => '%m', 'MMM' => '%b', 'MMMM' => '%B',
14
15
  'D' => '%-d', 'DD' => '%d', 'DDD' => '%-j', 'DDDD' => '%j',
15
16
  'd' => '%w', 'ddd' => '%a', 'dddd' => '%A',
@@ -155,7 +156,6 @@ module Grafana
155
156
 
156
157
  else
157
158
  # glob and all unknown
158
- # TODO add check for array value properly for all cases
159
159
  return "{#{value.join(',')}}" if multi? && value.is_a?(Array)
160
160
 
161
161
  value
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grafana
4
+ # This class standardizes all webcalls. Key functionality is to properly support HTTPS calls as a base functionality.
5
+ class WebRequest
6
+ attr_accessor :relative_url, :options
7
+
8
+ @ssl_cert = nil
9
+
10
+ class << self
11
+ attr_accessor :ssl_cert
12
+ end
13
+
14
+ # Initializes a specific HTTP request.
15
+ #
16
+ # Default (can be overridden, by specifying the options Hash):
17
+ # accept: 'application/json'
18
+ # request: Net::HTTP::Get
19
+ # content_type: 'application/json'
20
+ #
21
+ # @param base_url [String] URL which shall be queried
22
+ # @param options [Hash] options, which shall be merged to the request. Also allows `+logger+` option
23
+ def initialize(base_url, options = {})
24
+ @base_url = base_url
25
+ default_options = { accept: 'application/json', request: Net::HTTP::Get, content_type: 'application/json' }
26
+ @options = default_options.merge(options.reject { |k, _v| k == :logger && k == :relative_url })
27
+ @relative_url = options[:relative_url]
28
+ @logger = options[:logger] || Logger.new(nil)
29
+ end
30
+
31
+ # Executes the HTTP request
32
+ #
33
+ # @param timeout [Integer] number of seconds to wait, before the http request is cancelled, defaults to 60 seconds
34
+ # @return [Response] HTTP response object
35
+ def execute(timeout = nil)
36
+ timeout ||= 60
37
+
38
+ uri = URI.parse("#{@base_url}#{@relative_url}")
39
+ @http = Net::HTTP.new(uri.host, uri.port)
40
+ configure_ssl if @base_url =~ /^https/
41
+
42
+ @http.read_timeout = timeout.to_i
43
+
44
+ request = @options[:request].new(uri.request_uri)
45
+ request['Accept'] = @options[:accept] if @options[:accept]
46
+ request['Content-Type'] = @options[:content_type] if @options[:content_type]
47
+ request['Authorization'] = @options[:authorization] if @options[:authorization]
48
+ request.body = @options[:body]
49
+
50
+ @logger.debug("Requesting #{uri} with '#{@options[:body]}' and timeout '#{timeout}'")
51
+ response = @http.request(request)
52
+ @logger.debug("Received response #{response}")
53
+
54
+ response
55
+ end
56
+
57
+ private
58
+
59
+ def configure_ssl
60
+ @http.use_ssl = true
61
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
62
+ if self.class.ssl_cert && !File.file?(self.class.ssl_cert)
63
+ @logger.warn('SSL certificate file does not exist.')
64
+ elsif self.class.ssl_cert
65
+ @http.cert_store = OpenSSL::X509::Store.new
66
+ @http.cert_store.set_default_paths
67
+ @http.cert_store.add_file(self.class.ssl_cert)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrafanaReporter
4
+ # @abstract Override {#pre_process} and {#post_process} in subclass.
5
+ #
6
+ # Superclass containing everything for all queries towards grafana.
7
+ class AbstractQuery
8
+ attr_accessor :datasource
9
+ attr_writer :raw_query
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
40
+
41
+ else
42
+ raise GrafanaReporterError, "Internal error in AbstractQuery: given object is of type #{grafana_obj.class.name}, which is not supported"
43
+ end
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]
53
+ end
54
+
55
+ # @abstract
56
+ #
57
+ # Runs the whole process to receive values properly from this query:
58
+ # - calls {#pre_process}
59
+ # - executes this query against the {Grafana::AbstractDatasource} implementation instance
60
+ # - calls {#post_process}
61
+ #
62
+ # @return [Hash] result of the query in standardized format
63
+ def execute
64
+ return @result unless @result.nil?
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
+
75
+ pre_process
76
+ raise DatasourceNotSupportedError.new(@datasource, self) if @datasource.is_a?(Grafana::UnsupportedDatasource)
77
+
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?
92
+ post_process
93
+ @result
94
+ end
95
+
96
+ # Overwrite this function to extract a proper raw query value from this object.
97
+ #
98
+ # If the property +@raw_query+ is not set manually by the calling object, this
99
+ # method may be overwritten to extract the raw query from this object instead.
100
+ def raw_query
101
+ @raw_query
102
+ end
103
+
104
+ # @abstract
105
+ #
106
+ # Overwrite this function to perform all necessary actions, before the query is actually executed.
107
+ # Here you can e.g. set values of variables or similar.
108
+ #
109
+ # Especially for direct queries, it is essential to set the +@datasource+ variable at latest here in the
110
+ # subclass.
111
+ def pre_process
112
+ raise NotImplementedError
113
+ end
114
+
115
+ # @abstract
116
+ #
117
+ # Use this function to format the raw result of the @result variable to conform to the expected return value.
118
+ def post_process
119
+ raise NotImplementedError
120
+ end
121
+
122
+ # Transposes the given result.
123
+ #
124
+ # NOTE: Only the +:content+ of the given result hash is transposed. The +:header+ is ignored.
125
+ #
126
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
127
+ # @param transpose_variable [Grafana::Variable] true, if the result hash shall be transposed
128
+ # @return [Hash] transposed query result
129
+ def transpose(result, transpose_variable)
130
+ return result unless transpose_variable
131
+ return result unless transpose_variable.raw_value == 'true'
132
+
133
+ result[:content] = result[:content].transpose
134
+
135
+ result
136
+ end
137
+
138
+ # Filters columns out of the query result.
139
+ #
140
+ # Multiple columns may be filtered. Therefore the column titles have to be named in the
141
+ # {Grafana::Variable#raw_value} and have to be separated by +,+ (comma).
142
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
143
+ # @param filter_columns_variable [Grafana::Variable] column names, which shall be removed in the query result
144
+ # @return [Hash] filtered query result
145
+ def filter_columns(result, filter_columns_variable)
146
+ return result unless filter_columns_variable
147
+
148
+ filter_columns = filter_columns_variable.raw_value
149
+ filter_columns.split(',').each do |filter_column|
150
+ pos = result[:header].index(filter_column)
151
+
152
+ unless pos.nil?
153
+ result[:header].delete_at(pos)
154
+ result[:content].each { |row| row.delete_at(pos) }
155
+ end
156
+ end
157
+
158
+ result
159
+ end
160
+
161
+ # Uses the Kernel#format method to format values in the query results.
162
+ #
163
+ # The formatting will be applied separately for every column. Therefore the column formats have to be named
164
+ # in the {Grafana::Variable#raw_value} and have to be separated by +,+ (comma). If no value is specified for
165
+ # a column, no change will happen.
166
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
167
+ # @param formats [Grafana::Variable] formats, which shall be applied to the columns in the query result
168
+ # @return [Hash] formatted query result
169
+ def format_columns(result, formats)
170
+ return result unless formats
171
+
172
+ formats.text.split(',').each_index do |i|
173
+ format = formats.text.split(',')[i]
174
+ next if format.empty?
175
+
176
+ result[:content].map do |row|
177
+ next unless row.length > i
178
+
179
+ begin
180
+ row[i] = format % row[i] if row[i]
181
+ rescue StandardError => e
182
+ @grafana.logger.error(e.message)
183
+ row[i] = e.message
184
+ end
185
+ end
186
+ end
187
+ result
188
+ end
189
+
190
+ # Used to replace values in a query result according given configurations.
191
+ #
192
+ # The given variables will be applied to an appropriate column, depending
193
+ # on the naming of the variable. The variable name ending specifies the column,
194
+ # e.g. a variable named +replace_values_2+ will be applied to the second column.
195
+ #
196
+ # The {Grafana::Variable#text} needs to contain the replace specification.
197
+ # Multiple replacements can be specified by separating them with +,+. If a
198
+ # literal comma is needed, it can be escaped with a backslash: +\\,+.
199
+ #
200
+ # The rule will be separated from the replacement text with a colon +:+.
201
+ # If a literal colon is wanted, it can be escaped with a backslash: +\\:+.
202
+ #
203
+ # Examples:
204
+ # - Basic string replacement
205
+ # MyTest:ThisValue
206
+ # will replace all occurences of the text 'MyTest' with 'ThisValue'.
207
+ # - Number comparison
208
+ # <=10:OK
209
+ # will replace all values smaller or equal to 10 with 'OK'.
210
+ # - Regular expression
211
+ # ^[^ ]\\+ (\d+)$:\1 is the answer
212
+ # will replace all values matching the pattern, e.g. 'answerToAllQuestions 42' to
213
+ # '42 is the answer'. Important to know: the regular expressions always have to start
214
+ # with +^+ and end with +$+, i.e. the expression itself always has to match
215
+ # the whole content in one field.
216
+ # @param result [Hash] preformatted query result (see {Grafana::AbstractDatasource#request}.
217
+ # @param configs [Array<Grafana::Variable>] one variable for replacing values in one column
218
+ # @return [Hash] query result with replaced values
219
+ # TODO: make sure that caught errors are also visible in logger
220
+ def replace_values(result, configs)
221
+ return result if configs.empty?
222
+
223
+ configs.each do |key, formats|
224
+ cols = key.split('_')[2..-1].map(&:to_i)
225
+
226
+ formats.text.split(/(?<!\\),/).each_index do |j|
227
+ format = formats.text.split(/(?<!\\),/)[j]
228
+
229
+ arr = format.split(/(?<!\\):/)
230
+ raise MalformedReplaceValuesStatementError, format if arr.length != 2
231
+
232
+ k = arr[0]
233
+ v = arr[1]
234
+ k.gsub!(/\\([:,])/, '\1')
235
+ v.gsub!(/\\([:,])/, '\1')
236
+ result[:content].map do |row|
237
+ (row.length - 1).downto 0 do |i|
238
+ if cols.include?(i + 1) || cols.empty?
239
+
240
+ # handle regular expressions
241
+ if k.start_with?('^') && k.end_with?('$')
242
+ begin
243
+ row[i] = row[i].to_s.gsub(/#{k}/, v) if row[i].to_s =~ /#{k}/
244
+ rescue StandardError => e
245
+ @grafana.logger.error(e.message)
246
+ row[i] = e.message
247
+ end
248
+
249
+ # handle value comparisons
250
+ elsif (match = k.match(/^ *(?<operator>[<>]=?|<>|=) *(?<number>[+-]?\d+(?:\.\d+)?)$/))
251
+ skip = false
252
+ begin
253
+ val = Float(row[i])
254
+ rescue StandardError
255
+ # value cannot be converted to number, simply ignore it as the comparison does not fit here
256
+ skip = true
257
+ end
258
+
259
+ unless skip
260
+ begin
261
+ op = match[:operator].gsub(/^=$/, '==').gsub(/^<>$/, '!=')
262
+ if val.public_send(op.to_sym, Float(match[:number]))
263
+ row[i] = if v.include?('\\1')
264
+ v.gsub(/\\1/, row[i].to_s)
265
+ else
266
+ v
267
+ end
268
+ end
269
+ rescue StandardError => e
270
+ row[i] = e.message
271
+ end
272
+ end
273
+
274
+ # handle as normal comparison
275
+ elsif row[i].to_s == k
276
+ row[i] = v
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ result
285
+ end
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
+
305
+ # Used to translate the relative date strings used by grafana, e.g. +now-5d/w+ to the
306
+ # correct timestamp. Reason is that grafana does this in the frontend, which we have
307
+ # to emulate here for the reporter.
308
+ #
309
+ # Additionally providing this function the +report_time+ assures that all queries
310
+ # rendered within one report will use _exactly_ the same timestamp in those relative
311
+ # times, i.e. there shouldn't appear any time differences, no matter how long the
312
+ # report is running.
313
+ # @param orig_date [String] time string provided by grafana, usually +from+ or +to+.
314
+ # @param report_time [Grafana::Variable] report start time
315
+ # @param is_to_time [Boolean] true, if the time should be calculated for +to+, false if it shall be
316
+ # calculated for +from+
317
+ # @param timezone [Grafana::Variable] timezone to use, if not system timezone
318
+ # @return [String] translated date as timestamp string
319
+ def translate_date(orig_date, report_time, is_to_time, timezone = nil)
320
+ # TODO: add test case for creation of variable, if not given, maybe also print a warning
321
+ report_time ||= ::Grafana::Variable.new(Time.now.to_s)
322
+ orig_date = orig_date.raw_value if orig_date.is_a?(Grafana::Variable)
323
+ return (DateTime.parse(report_time.raw_value).to_time.to_i * 1000).to_s unless orig_date
324
+ return orig_date if orig_date =~ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
325
+ return orig_date if orig_date =~ /^\d+$/
326
+
327
+ # check if a relative date is mentioned
328
+ date_spec = orig_date.clone
329
+
330
+ date_spec = date_spec.gsub(/^now/, '')
331
+ raise TimeRangeUnknownError, orig_date unless date_spec
332
+
333
+ date = DateTime.parse(report_time.raw_value)
334
+ # TODO: allow from_translated or similar in ADOC template
335
+ date = date.new_offset(timezone.raw_value) if timezone
336
+
337
+ until date_spec.empty?
338
+ fit_match = date_spec.match(%r{^/(?<fit>[smhdwMy])})
339
+ if fit_match
340
+ date = fit_date(date, fit_match[:fit], is_to_time)
341
+ date_spec = date_spec.gsub(%r{^/#{fit_match[:fit]}}, '')
342
+ end
343
+
344
+ delta_match = date_spec.match(/^(?<op>(?:-|\+))(?<count>\d+)?(?<unit>[smhdwMy])/)
345
+ if delta_match
346
+ date = delta_date(date, "#{delta_match[:op]}#{delta_match[:count] || 1}".to_i, delta_match[:unit])
347
+ date_spec = date_spec.gsub(/^#{delta_match[:op]}#{delta_match[:count]}#{delta_match[:unit]}/, '')
348
+ end
349
+
350
+ raise TimeRangeUnknownError, orig_date unless fit_match || delta_match
351
+ end
352
+
353
+ # step back one second, if this is the 'to' time
354
+ date = (date.to_time - 1).to_datetime if is_to_time
355
+
356
+ (Time.at(date.to_time.to_i).to_i * 1000).to_s
357
+ end
358
+
359
+ private
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
+
400
+ # @return [Hash<String, Variable>] all grafana variables stored in this query, i.e. the variable name
401
+ # is prefixed with +var-+
402
+ def grafana_variables
403
+ @variables.select { |k, _v| k =~ /^var-.+/ }
404
+ end
405
+
406
+ def delta_date(date, delta_count, time_letter)
407
+ # substract specified time
408
+ case time_letter
409
+ when 's'
410
+ (date.to_time + (delta_count * 1)).to_datetime
411
+ when 'm'
412
+ (date.to_time + (delta_count * 60)).to_datetime
413
+ when 'h'
414
+ (date.to_time + (delta_count * 60 * 60)).to_datetime
415
+ when 'd'
416
+ date.next_day(delta_count)
417
+ when 'w'
418
+ date.next_day(delta_count * 7)
419
+ when 'M'
420
+ date.next_month(delta_count)
421
+ when 'y'
422
+ date.next_year(delta_count)
423
+ end
424
+ end
425
+
426
+ def fit_date(date, fit_letter, is_to_time)
427
+ # fit to specified time frame
428
+ case fit_letter
429
+ when 's'
430
+ date = DateTime.new(date.year, date.month, date.day, date.hour, date.min, date.sec, date.zone)
431
+ date = (date.to_time + 1).to_datetime if is_to_time
432
+ when 'm'
433
+ date = DateTime.new(date.year, date.month, date.day, date.hour, date.min, 0, date.zone)
434
+ date = (date.to_time + 60).to_datetime if is_to_time
435
+ when 'h'
436
+ date = DateTime.new(date.year, date.month, date.day, date.hour, 0, 0, date.zone)
437
+ date = (date.to_time + 60 * 60).to_datetime if is_to_time
438
+ when 'd'
439
+ date = DateTime.new(date.year, date.month, date.day, 0, 0, 0, date.zone)
440
+ date = date.next_day(1) if is_to_time
441
+ when 'w'
442
+ date = DateTime.new(date.year, date.month, date.day, 0, 0, 0, date.zone)
443
+ date = if date.wday.zero?
444
+ date.prev_day(7)
445
+ else
446
+ date.prev_day(date.wday - 1)
447
+ end
448
+ date = date.next_day(7) if is_to_time
449
+ when 'M'
450
+ date = DateTime.new(date.year, date.month, 1, 0, 0, 0, date.zone)
451
+ date = date.next_month if is_to_time
452
+ when 'y'
453
+ date = DateTime.new(date.year, 1, 1, 0, 0, 0, date.zone)
454
+ date = date.next_year if is_to_time
455
+ end
456
+
457
+ date
458
+ end
459
+ end
460
+ end