ruby-grafana-reporter 0.3.0 → 0.4.4

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +337 -170
  3. data/bin/ruby-grafana-reporter +5 -5
  4. data/lib/VERSION.rb +3 -2
  5. data/lib/grafana/abstract_datasource.rb +149 -0
  6. data/lib/grafana/dashboard.rb +1 -3
  7. data/lib/grafana/errors.rb +20 -5
  8. data/lib/grafana/grafana.rb +52 -57
  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 +37 -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 +10 -4
  16. data/lib/grafana/prometheus_datasource.rb +67 -0
  17. data/lib/grafana/sql_datasource.rb +70 -0
  18. data/lib/grafana/unsupported_datasource.rb +7 -0
  19. data/lib/grafana/variable.rb +27 -21
  20. data/lib/grafana/webrequest.rb +71 -0
  21. data/lib/grafana_reporter/abstract_query.rb +478 -0
  22. data/lib/grafana_reporter/abstract_report.rb +152 -18
  23. data/lib/grafana_reporter/abstract_table_format_strategy.rb +34 -0
  24. data/lib/grafana_reporter/alerts_table_query.rb +43 -0
  25. data/lib/grafana_reporter/annotations_table_query.rb +42 -0
  26. data/lib/grafana_reporter/application/application.rb +28 -25
  27. data/lib/grafana_reporter/application/webservice.rb +80 -39
  28. data/lib/grafana_reporter/asciidoctor/adoc_plain_table_format_strategy.rb +25 -0
  29. data/lib/grafana_reporter/asciidoctor/alerts_table_include_processor.rb +92 -0
  30. data/lib/grafana_reporter/asciidoctor/annotations_table_include_processor.rb +91 -0
  31. data/lib/grafana_reporter/asciidoctor/help.rb +336 -313
  32. data/lib/grafana_reporter/asciidoctor/panel_image_block_macro.rb +78 -0
  33. data/lib/grafana_reporter/asciidoctor/panel_image_inline_macro.rb +80 -0
  34. data/lib/grafana_reporter/asciidoctor/panel_property_inline_macro.rb +74 -0
  35. data/lib/grafana_reporter/asciidoctor/panel_query_table_include_processor.rb +99 -0
  36. data/lib/grafana_reporter/asciidoctor/panel_query_value_inline_macro.rb +93 -0
  37. data/lib/grafana_reporter/asciidoctor/processor_mixin.rb +50 -0
  38. data/lib/grafana_reporter/asciidoctor/report.rb +41 -82
  39. data/lib/grafana_reporter/asciidoctor/show_environment_include_processor.rb +46 -0
  40. data/lib/grafana_reporter/asciidoctor/show_help_include_processor.rb +35 -0
  41. data/lib/grafana_reporter/asciidoctor/sql_table_include_processor.rb +94 -0
  42. data/lib/grafana_reporter/asciidoctor/sql_value_inline_macro.rb +90 -0
  43. data/lib/grafana_reporter/asciidoctor/value_as_variable_include_processor.rb +90 -0
  44. data/lib/grafana_reporter/configuration.rb +26 -8
  45. data/lib/grafana_reporter/console_configuration_wizard.rb +109 -67
  46. data/lib/grafana_reporter/csv_table_format_strategy.rb +23 -0
  47. data/lib/grafana_reporter/demo_report_wizard.rb +104 -0
  48. data/lib/grafana_reporter/erb/demo_report_builder.rb +46 -0
  49. data/lib/grafana_reporter/erb/report.rb +36 -0
  50. data/lib/grafana_reporter/erb/report_jail.rb +21 -0
  51. data/lib/grafana_reporter/errors.rb +57 -0
  52. data/lib/grafana_reporter/logger/{two_way_logger.rb → two_way_delegate_logger.rb} +1 -1
  53. data/lib/grafana_reporter/panel_image_query.rb +25 -0
  54. data/lib/grafana_reporter/panel_property_query.rb +22 -0
  55. data/lib/grafana_reporter/query_value_query.rb +61 -0
  56. data/lib/grafana_reporter/report_webhook.rb +39 -0
  57. data/lib/ruby_grafana_extension.rb +8 -0
  58. data/lib/{ruby-grafana-reporter.rb → ruby_grafana_reporter.rb} +1 -3
  59. metadata +49 -38
  60. data/lib/grafana/abstract_panel_query.rb +0 -22
  61. data/lib/grafana/abstract_query.rb +0 -132
  62. data/lib/grafana/abstract_sql_query.rb +0 -51
  63. data/lib/grafana/panel_image_query.rb +0 -52
  64. data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +0 -101
  65. data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +0 -96
  66. data/lib/grafana_reporter/asciidoctor/errors.rb +0 -40
  67. data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +0 -92
  68. data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +0 -91
  69. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +0 -69
  70. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +0 -68
  71. data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +0 -61
  72. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +0 -78
  73. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +0 -73
  74. data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +0 -20
  75. data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +0 -43
  76. data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +0 -30
  77. data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +0 -70
  78. data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +0 -66
  79. data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +0 -88
  80. data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +0 -36
  81. data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +0 -28
  82. data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +0 -44
  83. data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +0 -40
  84. data/lib/grafana_reporter/asciidoctor/query_mixin.rb +0 -312
  85. data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +0 -42
  86. data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +0 -44
@@ -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',
@@ -62,92 +63,96 @@ module Grafana
62
63
  value = @raw_value
63
64
 
64
65
  # handle value 'All' properly
65
- # TODO: fix check for selection of All properly
66
- if (value == 'All') || (@text == 'All')
66
+ if value == '$__all'
67
67
  if !@config['options'].empty?
68
+ # this query contains predefined values, so capture them and format the values accordingly
69
+ # this happens either if type='custom' or a query, which is never updated
68
70
  value = @config['options'].map { |item| item['value'] }
69
- elsif !@config['query'].empty?
70
- # TODO: replace variables in this query, too
71
+
72
+ elsif @config['type'] == 'query' && !@config['query'].empty?
73
+ # TODO: replace variables in query, execute it and evaluate the results as if they were normally selected
74
+ # "multiFormat": contains variable replacement in "query", e.g. 'regex values' or 'glob'
75
+ # "datasource": contains name of datasource
71
76
  return @config['query']
72
- # TODO: handle 'All' value properly for query attributes
77
+
73
78
  else
74
- # TODO: how to handle All selection properly at this point?
79
+ # TODO: add support for variable type: 'datasource' and 'adhoc'
75
80
  end
76
81
  end
77
82
 
78
83
  case format
79
84
  when 'csv'
80
- return value.join(',').to_s if multi?
85
+ return value.join(',').to_s if multi? && value.is_a?(Array)
81
86
 
82
87
  value.to_s
83
88
 
84
89
  when 'distributed'
85
- return value.join(",#{name}=") if multi?
90
+ return value.join(",#{name}=") if multi? && value.is_a?(Array)
86
91
 
87
92
  value
88
93
  when 'doublequote'
89
- if multi?
94
+ if multi? && value.is_a?(Array)
90
95
  value = value.map { |item| "\"#{item.gsub(/\\/, '\\\\').gsub(/"/, '\\"')}\"" }
91
96
  return value.join(',')
92
97
  end
93
98
  "\"#{value.gsub(/"/, '\\"')}\""
94
99
 
95
100
  when 'json'
96
- if multi?
101
+ if multi? && value.is_a?(Array)
97
102
  value = value.map { |item| "\"#{item.gsub(/["\\]/, '\\\\\0')}\"" }
98
103
  return "[#{value.join(',')}]"
99
104
  end
100
105
  "\"#{value.gsub(/"/, '\\"')}\""
101
106
 
102
107
  when 'percentencode'
103
- value = "{#{value.join(',')}}" if multi?
108
+ value = "{#{value.join(',')}}" if multi? && value.is_a?(Array)
104
109
  ERB::Util.url_encode(value)
105
110
 
106
111
  when 'pipe'
107
- return value.join('|') if multi?
112
+ return value.join('|') if multi? && value.is_a?(Array)
108
113
 
109
114
  value
110
115
 
111
116
  when 'raw'
112
- return "{#{value.join(',')}}" if multi?
117
+ return "{#{value.join(',')}}" if multi? && value.is_a?(Array)
113
118
 
114
119
  value
115
120
 
116
121
  when 'regex'
117
- if multi?
122
+ if multi? && value.is_a?(Array)
118
123
  value = value.map { |item| item.gsub(%r{[/$.|\\]}, '\\\\\0') }
119
124
  return "(#{value.join('|')})"
120
125
  end
121
126
  value.gsub(%r{[/$.|\\]}, '\\\\\0')
122
127
 
123
128
  when 'singlequote'
124
- if multi?
129
+ if multi? && value.is_a?(Array)
125
130
  value = value.map { |item| "'#{item.gsub(/'/, '\\\\\0')}'" }
126
131
  return value.join(',')
127
132
  end
128
133
  "'#{value.gsub(/'/, '\\\\\0')}'"
129
134
 
130
135
  when 'sqlstring'
131
- if multi?
136
+ if multi? && value.is_a?(Array)
132
137
  value = value.map { |item| "'#{item.gsub(/'/, "''")}'" }
133
138
  return value.join(',')
134
139
  end
135
140
  "'#{value.gsub(/'/, "''")}'"
136
141
 
137
142
  when 'lucene'
138
- if multi?
143
+ if multi? && value.is_a?(Array)
139
144
  value = value.map { |item| "\"#{item.gsub(%r{[" |=/\\]}, '\\\\\0')}\"" }
140
145
  return "(#{value.join(' OR ')})"
141
146
  end
142
147
  value.gsub(%r{[" |=/\\]}, '\\\\\0')
143
148
 
144
149
  when /^date(?::(?<format>.*))?$/
145
- # TODO: validate how grafana handles multivariables with date format
150
+ # TODO: grafana does not seem to allow multivariables with date format - so properly handle here as well
146
151
  get_date_formatted(value, Regexp.last_match(1))
147
152
 
148
153
  when ''
149
154
  # default
150
- if multi?
155
+ if multi? && value.is_a?(Array)
151
156
  value = value.map { |item| "'#{item.gsub(/'/, "''")}'" }
152
157
  return value.join(',')
153
158
  end
@@ -155,14 +160,13 @@ module Grafana
155
160
 
156
161
  else
157
162
  # glob and all unknown
158
- # TODO add check for array value properly for all cases
159
163
  return "{#{value.join(',')}}" if multi? && value.is_a?(Array)
160
164
 
161
165
  value
162
166
  end
163
167
  end
164
168
 
165
- # @return [Boolean] true, if the value can contain multiple selections, i.e. is an Array
169
+ # @return [Boolean] true, if the value can contain multiple selections, i.e. can contain an Array
166
170
  def multi?
167
171
  return @config['multi'] unless @config['multi'].nil?
168
172
 
@@ -196,6 +200,8 @@ module Grafana
196
200
  until work_string.empty?
197
201
  tmp = work_string.scan(/^(?:M{1,4}|D{1,4}|d{1,4}|e|E|w{1,2}|W{1,2}|Y{4}|Y{2}|A|a|H{1,2}|
198
202
  h{1,2}|k{1,2}|m{1,2}|s{1,2}|S+|X)/x)
203
+
204
+ # TODO: add test for sub! and switch to non-modifying frozen string action
199
205
  if tmp.empty?
200
206
  matches << work_string[0]
201
207
  work_string.sub!(/^#{work_string[0]}/, '')
@@ -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,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'abstract_table_format_strategy'
4
+ require_relative 'csv_table_format_strategy'
5
+
6
+ module GrafanaReporter
7
+ # @abstract Override {#pre_process} and {#post_process} in subclass.
8
+ #
9
+ # Superclass containing everything for all queries towards grafana.
10
+ class AbstractQuery
11
+ attr_accessor :datasource
12
+ attr_writer :raw_query
13
+ attr_reader :variables, :result, :panel, :dashboard
14
+
15
+ def timeout
16
+ # TODO: check where value priorities should be evaluated
17
+ return @variables['timeout'].raw_value if @variables['timeout']
18
+ return @variables['grafana_default_timeout'].raw_value if @variables['grafana_default_timeout']
19
+
20
+ nil
21
+ end
22
+
23
+ # @param grafana_obj [Object] {Grafana::Grafana}, {Grafana::Dashboard} or {Grafana::Panel} object for which the query is executed
24
+ # @param opts [Hash] hash options, which may consist of:
25
+ # @option opts [Hash] :variables hash of variables, which shall be used to replace variable references in the query
26
+ # @option opts [Boolean] :ignore_dashboard_defaults True if {#assign_dashboard_defaults} should not be called
27
+ # @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
28
+ def initialize(grafana_obj, opts = {})
29
+ if grafana_obj.is_a?(Grafana::Panel)
30
+ @panel = grafana_obj
31
+ @dashboard = @panel.dashboard
32
+ @grafana = @dashboard.grafana
33
+
34
+ elsif grafana_obj.is_a?(Grafana::Dashboard)
35
+ @dashboard = grafana_obj
36
+ @grafana = @dashboard.grafana
37
+
38
+ elsif grafana_obj.is_a?(Grafana::Grafana)
39
+ @grafana = grafana_obj
40
+
41
+ elsif !grafana_obj
42
+ # nil given
43
+
44
+ else
45
+ raise GrafanaReporterError, "Internal error in AbstractQuery: given object is of type #{grafana_obj.class.name}, which is not supported"
46
+ end
47
+ @variables = {}
48
+ @variables['from'] = Grafana::Variable.new(nil)
49
+ @variables['to'] = Grafana::Variable.new(nil)
50
+
51
+ assign_dashboard_defaults unless opts[:ignore_dashboard_defaults]
52
+ opts[:variables].each { |k, v| assign_variable(k, v) } if opts[:variables].is_a?(Hash)
53
+
54
+ @translate_times = true
55
+ @translate_times = false if opts[:do_not_use_translated_times]
56
+ end
57
+
58
+ # @abstract
59
+ #
60
+ # Runs the whole process to receive values properly from this query:
61
+ # - calls {#pre_process}
62
+ # - executes this query against the {Grafana::AbstractDatasource} implementation instance
63
+ # - calls {#post_process}
64
+ #
65
+ # @return [Hash] result of the query in standardized format
66
+ def execute
67
+ return @result unless @result.nil?
68
+
69
+ from = @variables['from'].raw_value
70
+ to = @variables['to'].raw_value
71
+ if @translate_times
72
+ from = translate_date(@variables['from'], @variables['grafana_report_timestamp'], false, @variables['from_timezone'] ||
73
+ @variables['grafana_default_from_timezone'])
74
+ to = translate_date(@variables['to'], @variables['grafana_report_timestamp'], true, @variables['to_timezone'] ||
75
+ @variables['grafana_default_to_timezone'])
76
+ end
77
+
78
+ pre_process
79
+ raise DatasourceNotSupportedError.new(@datasource, self) if @datasource.is_a?(Grafana::UnsupportedDatasource)
80
+
81
+ begin
82
+ @result = @datasource.request(from: from, to: to, raw_query: raw_query, variables: grafana_variables,
83
+ prepared_request: @grafana.prepare_request, timeout: timeout)
84
+ rescue ::Grafana::GrafanaError
85
+ # grafana errors will be directly passed through
86
+ raise
87
+ rescue GrafanaReporterError
88
+ # grafana errors will be directly passed through
89
+ raise
90
+ rescue StandardError => e
91
+ raise DatasourceRequestInternalError.new(@datasource, e.message)
92
+ end
93
+
94
+ raise DatasourceRequestInvalidReturnValueError.new(@datasource, @result) unless datasource_response_valid?
95
+
96
+ post_process
97
+ @result
98
+ end
99
+
100
+ # Overwrite this function to extract a proper raw query value from this object.
101
+ #
102
+ # If the property +@raw_query+ is not set manually by the calling object, this
103
+ # method may be overwritten to extract the raw query from this object instead.
104
+ def raw_query
105
+ @raw_query
106
+ end
107
+
108
+ # @abstract
109
+ #
110
+ # Overwrite this function to perform all necessary actions, before the query is actually executed.
111
+ # Here you can e.g. set values of variables or similar.
112
+ #
113
+ # Especially for direct queries, it is essential to set the +@datasource+ variable at latest here in the
114
+ # subclass.
115
+ def pre_process
116
+ raise NotImplementedError
117
+ end
118
+
119
+ # @abstract
120
+ #
121
+ # Use this function to format the raw result of the @result variable to conform to the expected return value.
122
+ def post_process
123
+ raise NotImplementedError
124
+ end
125
+
126
+ # Transposes the given result.
127
+ #
128
+ # NOTE: Only the +:content+ of the given result hash is transposed. The +:header+ is ignored.
129
+ #
130
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
131
+ # @param transpose_variable [Grafana::Variable] true, if the result hash shall be transposed
132
+ # @return [Hash] transposed query result
133
+ def transpose(result, transpose_variable)
134
+ return result unless transpose_variable
135
+ return result unless transpose_variable.raw_value == 'true'
136
+
137
+ result[:content] = result[:content].transpose
138
+
139
+ result
140
+ end
141
+
142
+ # Filters columns out of the query result.
143
+ #
144
+ # Multiple columns may be filtered. Therefore the column titles have to be named in the
145
+ # {Grafana::Variable#raw_value} and have to be separated by +,+ (comma).
146
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
147
+ # @param filter_columns_variable [Grafana::Variable] column names, which shall be removed in the query result
148
+ # @return [Hash] filtered query result
149
+ def filter_columns(result, filter_columns_variable)
150
+ return result unless filter_columns_variable
151
+
152
+ filter_columns = filter_columns_variable.raw_value
153
+ filter_columns.split(',').each do |filter_column|
154
+ pos = result[:header].index(filter_column)
155
+
156
+ unless pos.nil?
157
+ result[:header].delete_at(pos)
158
+ result[:content].each { |row| row.delete_at(pos) }
159
+ end
160
+ end
161
+
162
+ result
163
+ end
164
+
165
+ # Uses the Kernel#format method to format values in the query results.
166
+ #
167
+ # The formatting will be applied separately for every column. Therefore the column formats have to be named
168
+ # in the {Grafana::Variable#raw_value} and have to be separated by +,+ (comma). If no value is specified for
169
+ # a column, no change will happen.
170
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
171
+ # @param formats [Grafana::Variable] formats, which shall be applied to the columns in the query result
172
+ # @return [Hash] formatted query result
173
+ def format_columns(result, formats)
174
+ return result unless formats
175
+
176
+ formats.text.split(',').each_index do |i|
177
+ format = formats.text.split(',')[i]
178
+ next if format.empty?
179
+
180
+ result[:content].map do |row|
181
+ next unless row.length > i
182
+
183
+ begin
184
+ row[i] = format % row[i] if row[i]
185
+ rescue StandardError => e
186
+ @grafana.logger.error(e.message)
187
+ row[i] = e.message
188
+ end
189
+ end
190
+ end
191
+ result
192
+ end
193
+
194
+ # Used to replace values in a query result according given configurations.
195
+ #
196
+ # The given variables will be applied to an appropriate column, depending
197
+ # on the naming of the variable. The variable name ending specifies the column,
198
+ # e.g. a variable named +replace_values_2+ will be applied to the second column.
199
+ #
200
+ # The {Grafana::Variable#text} needs to contain the replace specification.
201
+ # Multiple replacements can be specified by separating them with +,+. If a
202
+ # literal comma is needed, it can be escaped with a backslash: +\\,+.
203
+ #
204
+ # The rule will be separated from the replacement text with a colon +:+.
205
+ # If a literal colon is wanted, it can be escaped with a backslash: +\\:+.
206
+ #
207
+ # Examples:
208
+ # - Basic string replacement
209
+ # MyTest:ThisValue
210
+ # will replace all occurences of the text 'MyTest' with 'ThisValue'.
211
+ # - Number comparison
212
+ # <=10:OK
213
+ # will replace all values smaller or equal to 10 with 'OK'.
214
+ # - Regular expression
215
+ # ^[^ ]\\+ (\d+)$:\1 is the answer
216
+ # will replace all values matching the pattern, e.g. 'answerToAllQuestions 42' to
217
+ # '42 is the answer'. Important to know: the regular expressions always have to start
218
+ # with +^+ and end with +$+, i.e. the expression itself always has to match
219
+ # the whole content in one field.
220
+ # @param result [Hash] preformatted query result (see {Grafana::AbstractDatasource#request}.
221
+ # @param configs [Array<Grafana::Variable>] one variable for replacing values in one column
222
+ # @return [Hash] query result with replaced values
223
+ def replace_values(result, configs)
224
+ return result if configs.empty?
225
+
226
+ configs.each do |key, formats|
227
+ cols = key.split('_')[2..-1].map(&:to_i)
228
+
229
+ formats.text.split(/(?<!\\),/).each_index do |j|
230
+ format = formats.text.split(/(?<!\\),/)[j]
231
+
232
+ arr = format.split(/(?<!\\):/)
233
+ raise MalformedReplaceValuesStatementError, format if arr.length != 2
234
+
235
+ k = arr[0]
236
+ v = arr[1]
237
+
238
+ # allow keys and values to contain escaped colons or commas
239
+ k = k.gsub(/\\([:,])/, '\1')
240
+ v = v.gsub(/\\([:,])/, '\1')
241
+
242
+ result[:content].map do |row|
243
+ (row.length - 1).downto 0 do |i|
244
+ if cols.include?(i + 1) || cols.empty?
245
+
246
+ # handle regular expressions
247
+ if k.start_with?('^') && k.end_with?('$')
248
+ begin
249
+ row[i] = row[i].to_s.gsub(/#{k}/, v) if row[i].to_s =~ /#{k}/
250
+ rescue StandardError => e
251
+ @grafana.logger.error(e.message)
252
+ row[i] = e.message
253
+ end
254
+
255
+ # handle value comparisons
256
+ elsif (match = k.match(/^ *(?<operator>[<>]=?|<>|=) *(?<number>[+-]?\d+(?:\.\d+)?)$/))
257
+ skip = false
258
+ begin
259
+ val = Float(row[i])
260
+ rescue StandardError
261
+ # value cannot be converted to number, simply ignore it as the comparison does not fit here
262
+ skip = true
263
+ end
264
+
265
+ unless skip
266
+ begin
267
+ op = match[:operator].gsub(/^=$/, '==').gsub(/^<>$/, '!=')
268
+ if val.public_send(op.to_sym, Float(match[:number]))
269
+ row[i] = if v.include?('\\1')
270
+ v.gsub(/\\1/, row[i].to_s)
271
+ else
272
+ v
273
+ end
274
+ end
275
+ rescue StandardError => e
276
+ @grafana.logger.error(e.message)
277
+ row[i] = e.message
278
+ end
279
+ end
280
+
281
+ # handle as normal comparison
282
+ elsif row[i].to_s == k
283
+ row[i] = v
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ result
292
+ end
293
+
294
+ # Used to build a table output in a custom format.
295
+ # @param result [Hash] preformatted sql hash, (see {Grafana::AbstractDatasource#request})
296
+ # @param opts [Hash] options for the formatting:
297
+ # @option opts [Grafana::Variable] :row_divider requested row divider for the result table, only to be used with table_formatter `adoc_deprecated`
298
+ # @option opts [Grafana::Variable] :column_divider requested row divider for the result table, only to be used with table_formatter `adoc_deprecated`
299
+ # @option opts [Grafana::Variable] :include_headline specifies if table should contain headline, defaults to false
300
+ # @option opts [Grafana::Variable] :table_formatter specifies which formatter shall be used, defaults to 'csv'
301
+ # @return [String] table in custom output format
302
+ def format_table_output(result, opts)
303
+ opts = { include_headline: Grafana::Variable.new('false'),
304
+ table_formatter: Grafana::Variable.new('csv'),
305
+ row_divider: Grafana::Variable.new('| '),
306
+ column_divider: Grafana::Variable.new(' | ') }.merge(opts.delete_if {|_k, v| v.nil? })
307
+
308
+ if opts[:table_formatter].raw_value == 'adoc_deprecated'
309
+ @grafana.logger.warn("You are using deprecated 'table_formatter' named 'adoc_deprecated', which will be "\
310
+ "removed in a future version. Start using 'adoc' or register your own implementation "\
311
+ "of AbstractTableFormatStrategy.")
312
+ return result[:content].map do |row|
313
+ opts[:row_divider].raw_value + row.map do |item|
314
+ item.to_s.gsub('|', '\\|')
315
+ end.join(opts[:column_divider].raw_value)
316
+ end
317
+ end
318
+
319
+ AbstractTableFormatStrategy.get(opts[:table_formatter].raw_value).format(result, opts[:include_headline].raw_value.downcase == 'true')
320
+ end
321
+
322
+ # Used to translate the relative date strings used by grafana, e.g. +now-5d/w+ to the
323
+ # correct timestamp. Reason is that grafana does this in the frontend, which we have
324
+ # to emulate here for the reporter.
325
+ #
326
+ # Additionally providing this function the +report_time+ assures that all queries
327
+ # rendered within one report will use _exactly_ the same timestamp in those relative
328
+ # times, i.e. there shouldn't appear any time differences, no matter how long the
329
+ # report is running.
330
+ # @param orig_date [String] time string provided by grafana, usually +from+ or +to+.
331
+ # @param report_time [Grafana::Variable] report start time
332
+ # @param is_to_time [Boolean] true, if the time should be calculated for +to+, false if it shall be
333
+ # calculated for +from+
334
+ # @param timezone [Grafana::Variable] timezone to use, if not system timezone
335
+ # @return [String] translated date as timestamp string
336
+ def translate_date(orig_date, report_time, is_to_time, timezone = nil)
337
+ @grafana.logger.warn("#translate_date has been called without 'report_time' - using current time as fallback.") unless report_time
338
+ report_time ||= ::Grafana::Variable.new(Time.now.to_s)
339
+ orig_date = orig_date.raw_value if orig_date.is_a?(Grafana::Variable)
340
+
341
+ return (DateTime.parse(report_time.raw_value).to_time.to_i * 1000).to_s unless orig_date
342
+ return orig_date if orig_date =~ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
343
+ return orig_date if orig_date =~ /^\d+$/
344
+
345
+ # check if a relative date is mentioned
346
+ date_spec = orig_date.clone
347
+
348
+ date_spec = date_spec.gsub(/^now/, '')
349
+ raise TimeRangeUnknownError, orig_date unless date_spec
350
+
351
+ date = DateTime.parse(report_time.raw_value)
352
+ # TODO: allow from_translated or similar in ADOC template
353
+ date = date.new_offset(timezone.raw_value) if timezone
354
+
355
+ until date_spec.empty?
356
+ fit_match = date_spec.match(%r{^/(?<fit>[smhdwMy])})
357
+ if fit_match
358
+ date = fit_date(date, fit_match[:fit], is_to_time)
359
+ date_spec = date_spec.gsub(%r{^/#{fit_match[:fit]}}, '')
360
+ end
361
+
362
+ delta_match = date_spec.match(/^(?<op>(?:-|\+))(?<count>\d+)?(?<unit>[smhdwMy])/)
363
+ if delta_match
364
+ date = delta_date(date, "#{delta_match[:op]}#{delta_match[:count] || 1}".to_i, delta_match[:unit])
365
+ date_spec = date_spec.gsub(/^#{delta_match[:op]}#{delta_match[:count]}#{delta_match[:unit]}/, '')
366
+ end
367
+
368
+ raise TimeRangeUnknownError, orig_date unless fit_match || delta_match
369
+ end
370
+
371
+ # step back one second, if this is the 'to' time
372
+ date = (date.to_time - 1).to_datetime if is_to_time
373
+
374
+ (Time.at(date.to_time.to_i).to_i * 1000).to_s
375
+ end
376
+
377
+ private
378
+
379
+ # Used to specify variables to be used for this query. This method ensures, that only the values of the
380
+ # {Grafana::Variable} stored in the +variables+ Array are overwritten.
381
+ # @param name [String] name of the variable to set
382
+ # @param variable [Grafana::Variable] variable from which the {Grafana::Variable#raw_value} will be assigned to the query variables
383
+ def assign_variable(name, variable)
384
+ variable = Grafana::Variable.new(variable) unless variable.is_a?(Grafana::Variable)
385
+
386
+ @variables[name] ||= variable
387
+ @variables[name].raw_value = variable.raw_value
388
+ end
389
+
390
+ # Sets default configurations from the given {Grafana::Dashboard} and store them as settings in the
391
+ # {AbstractQuery}.
392
+ #
393
+ # Following data is extracted:
394
+ # - +from+, by {Grafana::Dashboard#from_time}
395
+ # - +to+, by {Grafana::Dashboard#to_time}
396
+ # - and all variables as {Grafana::Variable}, prefixed with +var-+, as grafana also does it
397
+ def assign_dashboard_defaults
398
+ return unless @dashboard
399
+
400
+ assign_variable('from', @dashboard.from_time)
401
+ assign_variable('to', @dashboard.to_time)
402
+ @dashboard.variables.each { |item| assign_variable("var-#{item.name}", item) }
403
+ end
404
+
405
+ def datasource_response_valid?
406
+ return false if @result.nil?
407
+ return false unless @result.is_a?(Hash)
408
+ # TODO: compare how empty valid responses look like in grafana
409
+ return true if @result.empty?
410
+ return false unless @result.key?(:header)
411
+ return false unless @result.key?(:content)
412
+ return false unless @result[:header].is_a?(Array)
413
+ return false unless @result[:content].is_a?(Array)
414
+
415
+ true
416
+ end
417
+
418
+ # @return [Hash<String, Variable>] all grafana variables stored in this query, i.e. the variable name
419
+ # is prefixed with +var-+
420
+ def grafana_variables
421
+ @variables.select { |k, _v| k =~ /^var-.+/ }
422
+ end
423
+
424
+ def delta_date(date, delta_count, time_letter)
425
+ # substract specified time
426
+ case time_letter
427
+ when 's'
428
+ (date.to_time + (delta_count * 1)).to_datetime
429
+ when 'm'
430
+ (date.to_time + (delta_count * 60)).to_datetime
431
+ when 'h'
432
+ (date.to_time + (delta_count * 60 * 60)).to_datetime
433
+ when 'd'
434
+ date.next_day(delta_count)
435
+ when 'w'
436
+ date.next_day(delta_count * 7)
437
+ when 'M'
438
+ date.next_month(delta_count)
439
+ when 'y'
440
+ date.next_year(delta_count)
441
+ end
442
+ end
443
+
444
+ def fit_date(date, fit_letter, is_to_time)
445
+ # fit to specified time frame
446
+ case fit_letter
447
+ when 's'
448
+ date = DateTime.new(date.year, date.month, date.day, date.hour, date.min, date.sec, date.zone)
449
+ date = (date.to_time + 1).to_datetime if is_to_time
450
+ when 'm'
451
+ date = DateTime.new(date.year, date.month, date.day, date.hour, date.min, 0, date.zone)
452
+ date = (date.to_time + 60).to_datetime if is_to_time
453
+ when 'h'
454
+ date = DateTime.new(date.year, date.month, date.day, date.hour, 0, 0, date.zone)
455
+ date = (date.to_time + 60 * 60).to_datetime if is_to_time
456
+ when 'd'
457
+ date = DateTime.new(date.year, date.month, date.day, 0, 0, 0, date.zone)
458
+ date = date.next_day(1) if is_to_time
459
+ when 'w'
460
+ date = DateTime.new(date.year, date.month, date.day, 0, 0, 0, date.zone)
461
+ date = if date.wday.zero?
462
+ date.prev_day(7)
463
+ else
464
+ date.prev_day(date.wday - 1)
465
+ end
466
+ date = date.next_day(7) if is_to_time
467
+ when 'M'
468
+ date = DateTime.new(date.year, date.month, 1, 0, 0, 0, date.zone)
469
+ date = date.next_month if is_to_time
470
+ when 'y'
471
+ date = DateTime.new(date.year, 1, 1, 0, 0, 0, date.zone)
472
+ date = date.next_year if is_to_time
473
+ end
474
+
475
+ date
476
+ end
477
+ end
478
+ end