ruby-grafana-reporter 0.1.6 → 0.3.0

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +0 -0
  3. data/README.md +95 -173
  4. data/bin/ruby-grafana-reporter +5 -0
  5. data/lib/VERSION.rb +5 -3
  6. data/lib/grafana/abstract_panel_query.rb +22 -20
  7. data/lib/grafana/abstract_query.rb +132 -127
  8. data/lib/grafana/abstract_sql_query.rb +51 -42
  9. data/lib/grafana/dashboard.rb +77 -66
  10. data/lib/grafana/errors.rb +66 -61
  11. data/lib/grafana/grafana.rb +133 -131
  12. data/lib/grafana/panel.rb +41 -39
  13. data/lib/grafana/panel_image_query.rb +52 -49
  14. data/lib/grafana/variable.rb +217 -259
  15. data/lib/grafana_reporter/abstract_report.rb +112 -109
  16. data/lib/grafana_reporter/application/application.rb +158 -229
  17. data/lib/grafana_reporter/application/errors.rb +33 -30
  18. data/lib/grafana_reporter/application/webservice.rb +230 -0
  19. data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +101 -99
  20. data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +96 -96
  21. data/lib/grafana_reporter/asciidoctor/errors.rb +40 -37
  22. data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +92 -86
  23. data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +91 -86
  24. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +69 -67
  25. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +68 -65
  26. data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +61 -58
  27. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +78 -75
  28. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +73 -70
  29. data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +20 -18
  30. data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +43 -41
  31. data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +30 -202
  32. data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +70 -67
  33. data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +66 -65
  34. data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +88 -57
  35. data/lib/grafana_reporter/asciidoctor/help.rb +435 -0
  36. data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +36 -32
  37. data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +28 -23
  38. data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +44 -43
  39. data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +40 -36
  40. data/lib/grafana_reporter/asciidoctor/query_mixin.rb +312 -309
  41. data/lib/grafana_reporter/asciidoctor/report.rb +179 -159
  42. data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +42 -34
  43. data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +44 -32
  44. data/lib/grafana_reporter/configuration.rb +304 -326
  45. data/lib/grafana_reporter/console_configuration_wizard.rb +269 -0
  46. data/lib/grafana_reporter/errors.rb +48 -38
  47. data/lib/grafana_reporter/logger/two_way_logger.rb +58 -52
  48. data/lib/ruby-grafana-reporter.rb +32 -27
  49. metadata +116 -16
@@ -1,30 +1,33 @@
1
- module GrafanaReporter
2
- module Application
3
- # General grafana application error, from which the specific errors
4
- # inherit.
5
- class ApplicationError < GrafanaReporterError
6
- end
7
-
8
- # Thrown if a non existing template has been specified.
9
- class MissingTemplateError < ApplicationError
10
- def initialize(template)
11
- super("Given report template '#{template}' is not a valid template.")
12
- end
13
- end
14
-
15
- # Thrown, if a webservice request has been requested, which could not be
16
- # handled.
17
- class WebserviceUnknownPathError < ApplicationError
18
- def initialize(request)
19
- super("Request '#{request}' calls an unknown path for this webservice.")
20
- end
21
- end
22
-
23
- # Thrown, if an internal error appeared during creation of the report.
24
- class WebserviceGeneralRenderingError < ApplicationError
25
- def initialize(error)
26
- super("Could not render report because of internal error: #{error}")
27
- end
28
- end
29
- end
30
- end
1
+ # frozen_string_literal: true
2
+
3
+ module GrafanaReporter
4
+ module Application
5
+ # General grafana application error, from which the specific errors
6
+ # inherit.
7
+ class ApplicationError < GrafanaReporterError
8
+ end
9
+
10
+ # Thrown, if the '-s' parameter is not configured with exactly one variable
11
+ # name and one value.
12
+ class ParameterValueError < ApplicationError
13
+ def initialize(length)
14
+ super("Parameter '-s' needs exactly two values separated by comma, received #{length}.")
15
+ end
16
+ end
17
+
18
+ # Thrown, if a webservice request has been requested, which could not be
19
+ # handled.
20
+ class WebserviceUnknownPathError < ApplicationError
21
+ def initialize(request)
22
+ super("Request '#{request}' calls an unknown path for this webservice.")
23
+ end
24
+ end
25
+
26
+ # Thrown, if an internal error appeared during creation of the report.
27
+ class WebserviceGeneralRenderingError < ApplicationError
28
+ def initialize(error)
29
+ super("Could not render report because of internal error: #{error}")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrafanaReporter
4
+ module Application
5
+ # This class provides the webservice for the reporter application. It does not
6
+ # make use of `webrick` or similar, so that it can be used without futher dependencies
7
+ # in conjunction with the standard asciidoctor docker container.
8
+ class Webservice
9
+ def initialize(config)
10
+ @reports = []
11
+ @config = config
12
+ @logger = config.logger
13
+ end
14
+
15
+ # Runs the webservice with the current set {Configuration} object.
16
+ def run
17
+ # start webserver
18
+ @server = TCPServer.new(@config.webserver_port)
19
+ @logger.info("Server listening on port #{@config.webserver_port}...")
20
+
21
+ @progress_reporter = Thread.new {}
22
+
23
+ accept_requests_loop
24
+ end
25
+
26
+ private
27
+
28
+ def accept_requests_loop
29
+ loop do
30
+ # step 1) accept incoming connection
31
+ socket = @server.accept
32
+
33
+ # step 2) print the request headers (separated by a blank line e.g. \r\n)
34
+ request = ''
35
+ line = ''
36
+ begin
37
+ until line == "\r\n"
38
+ line = socket.readline
39
+ request += line
40
+ end
41
+ rescue EOFError => e
42
+ @logger.debug("Webserver EOFError: #{e.message}")
43
+ end
44
+
45
+ begin
46
+ response = handle_request(request)
47
+ socket.write response
48
+ rescue WebserviceUnknownPathError => e
49
+ @logger.debug(e.message)
50
+ socket.write http_response(404, '', e.message)
51
+ rescue MissingTemplateError => e
52
+ @logger.error(e.message)
53
+ socket.write http_response(400, 'Bad Request', e.message)
54
+ rescue WebserviceGeneralRenderingError => e
55
+ @logger.fatal(e.message)
56
+ socket.write http_response(400, 'Bad Request', e.message)
57
+ rescue StandardError => e
58
+ @logger.fatal(e.message)
59
+ socket.write http_response(400, 'Bad Request', e.message)
60
+ ensure
61
+ socket.close
62
+ end
63
+
64
+ log_report_progress
65
+ clean_outdated_temporary_reports
66
+ end
67
+ end
68
+
69
+ def log_report_progress
70
+ return if @progress_reporter.alive?
71
+
72
+ @progress_reporter = Thread.new do
73
+ running_reports = @reports.reject(&:done)
74
+ until running_reports.empty?
75
+ unless running_reports.empty?
76
+ @logger.info("#{running_reports.length} report(s) in progress: "\
77
+ "#{running_reports.map do |report|
78
+ "#{(report.progress * 100).to_i}% (running #{report.execution_time.to_i} secs)"
79
+ end.join(', ')}")
80
+ end
81
+ sleep 5
82
+ running_reports = @reports.reject(&:done)
83
+ end
84
+ # puts "no more running reports - stopping to report progress"
85
+ end
86
+ end
87
+
88
+ def clean_outdated_temporary_reports
89
+ clean_time = Time.now - 60 * 60 * @config.report_retention
90
+ @reports.select { |report| report.done && clean_time > report.end_time }.each do |report|
91
+ @reports.delete(report).delete_file
92
+ end
93
+ end
94
+
95
+ def handle_request(request)
96
+ raise WebserviceUnknownPathError, request.split("\r\n")[0] if request.nil?
97
+ raise WebserviceUnknownPathError, request.split("\r\n")[0] if request.split("\r\n")[0].nil?
98
+
99
+ query_string = request.split("\r\n")[0].gsub(%r{(?:[^?]+\?)(.*)(?: HTTP/.*)$}, '\1')
100
+ query_parameters = CGI.parse(query_string)
101
+
102
+ @logger.debug("Received request: #{request.split("\r\n")[0]}")
103
+ @logger.debug("query_parameters: #{query_parameters}")
104
+
105
+ # read URL parameters
106
+ attrs = {}
107
+ query_parameters.each do |k, v|
108
+ attrs[k] = v.length == 1 ? v[0] : v
109
+ end
110
+
111
+ case request.split("\r\n")[0]
112
+ when %r{^GET /render[? ]}
113
+ return render_report(attrs)
114
+
115
+ when %r{^GET /overview[? ]}
116
+ # show overview for current reports
117
+ return get_reports_status_as_html(@reports)
118
+
119
+ when %r{^GET /view_report[? ]}
120
+ return view_report(attrs)
121
+
122
+ when %r{^GET /cancel_report[? ]}
123
+ return cancel_report(attrs)
124
+
125
+ when %r{^GET /view_log[? ]}
126
+ return view_log(attrs)
127
+ end
128
+
129
+ raise WebserviceUnknownPathError, request.split("\r\n")[0]
130
+ end
131
+
132
+ def view_log(attrs)
133
+ # view report if already available, or show status view
134
+ report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
135
+ raise WebserviceGeneralRenderingError, 'view_log has been called without valid id' if report.nil?
136
+
137
+ content = report.full_log
138
+
139
+ http_response(200, 'OK', content, "Content-Type": 'text/plain')
140
+ end
141
+
142
+ def cancel_report(attrs)
143
+ # view report if already available, or show status view
144
+ report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
145
+ raise WebserviceGeneralRenderingError, 'cancel_report has been called without valid id' if report.nil?
146
+
147
+ report.cancel! unless report.done
148
+
149
+ # redirect to view_report page
150
+ http_response(302, 'Found', nil, Location: "/view_report?report_id=#{report.object_id}")
151
+ end
152
+
153
+ def view_report(attrs)
154
+ # view report if already available, or show status view
155
+ report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
156
+ raise WebserviceGeneralRenderingError, 'view_report has been called without valid id' if report.nil?
157
+
158
+ # show report status
159
+ return get_reports_status_as_html([report]) if !report.done || !report.error.empty?
160
+
161
+ # provide report
162
+ @logger.debug("Returning PDF report at #{report.path}")
163
+ content = File.read(report.path, mode: 'rb')
164
+ return http_response(200, 'OK', content, "Content-Type": 'application/pdf') if content.start_with?('%PDF')
165
+
166
+ http_response(200, 'OK', content, "Content-Type": 'application/octet-stream',
167
+ "Content-Disposition": "attachment; filename=report_#{attrs['report_id']}.zip")
168
+ end
169
+
170
+ def render_report(attrs)
171
+ # build report
172
+ template_file = "#{@config.templates_folder}#{attrs['var-template']}.adoc"
173
+
174
+ file = Tempfile.new('gf_pdf_', @config.reports_folder)
175
+ begin
176
+ FileUtils.chmod('+r', file.path)
177
+ rescue StandardError => e
178
+ @logger.debug("File permissions could not be set for #{file.path}: #{e.message}")
179
+ end
180
+
181
+ report = @config.report_class.new(@config, template_file, file, attrs)
182
+ Thread.new do
183
+ report.create_report
184
+ end
185
+ @reports << report
186
+
187
+ http_response(302, 'Found', nil, Location: "/view_report?report_id=#{report.object_id}")
188
+ end
189
+
190
+ def get_reports_status_as_html(reports)
191
+ i = reports.length
192
+
193
+ content = '<html>'\
194
+ '<head></head>'\
195
+ '<body>'\
196
+ '<table>'\
197
+ '<thead>'\
198
+ '<th>#</th>'\
199
+ '<th>Start Time</th>'\
200
+ '<th>End Time</th>'\
201
+ '<th>Template</th>'\
202
+ '<th>Execution time</th>'\
203
+ '<th>Status</th>'\
204
+ '<th>Error</th>'\
205
+ '<th>Action</th>'\
206
+ '</thead>' +
207
+ reports.reverse.map do |report|
208
+ '<tr>'\
209
+ "<td>#{i -= 1}</td>"\
210
+ "<td>#{report.start_time}</td>"\
211
+ "<td>#{report.end_time}</td>"\
212
+ "<td>#{report.template}</td>"\
213
+ "<td>#{report.execution_time.to_i} secs</td><td>#{report.status} (#{(report.progress * 100).to_i}%)</td><td>#{report.error.join('<br>')}</td>"\
214
+ "<td>#{!report.done && !report.cancel ? "<a href=\"/cancel_report?report_id=#{report.object_id}\">Cancel</a>&nbsp;" : ''}"\
215
+ "#{(report.status == 'finished') || (report.status == 'cancelled') ? "<a href=\"/view_report?report_id=#{report.object_id}\">View</a>&nbsp;" : '&nbsp;'}"\
216
+ "<a href=\"/view_log?report_id=#{report.object_id}\">Log</a></td>"\
217
+ '</tr>'
218
+ end.join('') +
219
+ '</table></body></html>'
220
+
221
+ http_response(200, 'OK', content, "Content-Type": 'text/html')
222
+ end
223
+
224
+ def http_response(code, text, body, opts = {})
225
+ "HTTP/1.1 #{code} #{text}\r\n#{opts.map { |k, v| "#{k}: #{v}" }.join("\r\n")}"\
226
+ "#{body ? "\r\nContent-Length: #{body.to_s.bytesize}" : ''}\r\n\r\n#{body}"
227
+ end
228
+ end
229
+ end
230
+ end
@@ -1,99 +1,101 @@
1
- require_relative 'query_mixin'
2
-
3
- module GrafanaReporter
4
- module Asciidoctor
5
- # This class is used to query alerts from grafana.
6
- class AlertsTableQuery < Grafana::AbstractQuery
7
- include QueryMixin
8
-
9
- # @option opts [Grafana::Dashboard] :dashboard dashboard, if alerts shall be filtered for a dashboard
10
- # @option opts [Grafnaa::Oanel] :panel panel, if alerts shall be filtered for a panel
11
- def initialize(opts = {})
12
- super()
13
-
14
- @dashboard = opts[:dashboard]
15
- @panel = opts[:panel]
16
- @dashboard = @panel.dashboard if @panel
17
-
18
- extract_dashboard_variables(@dashboard) if @dashboard
19
- end
20
-
21
- # @return [String] URL for querying alerts
22
- def url
23
- '/api/alerts' + url_parameters
24
- end
25
-
26
- # @return [Hash] empty hash object
27
- def request
28
- {}
29
- end
30
-
31
- # Check if mandatory {Grafana::Variable} +columns+ is specified in variables.
32
- #
33
- # The value of the +columns+ variable has to be a comma separated list of column titles, which
34
- # need to be included in the following list:
35
- # - limit
36
- # - dashboardId
37
- # - panelId
38
- # - query
39
- # - state
40
- # - folderId
41
- # - dashboardQuery
42
- # - dashboardTag
43
- # @return [void]
44
- def pre_process(_grafana)
45
- raise MissingMandatoryAttributeError, 'columns' unless @variables['columns']
46
-
47
- @from = translate_date(@from, @variables['grafana-report-timestamp'], false)
48
- @to = translate_date(@to, @variables['grafana-report-timestamp'], true)
49
- end
50
-
51
- # Filter the query result for the given columns and sets the result in the preformatted SQL
52
- # result stlye.
53
-
54
- # Additionally it applies {QueryMixin#format_columns}, {QueryMixin#replace_values} and
55
- # {QueryMixin#filter_columns}.
56
- # @return [void]
57
- def post_process
58
- # extract data from returned json
59
- result = JSON.parse(@result.body)
60
- content = []
61
- begin
62
- result.each { |item| content << item.fetch_values(*@variables['columns'].raw_value.split(',')) }
63
- rescue KeyError => e
64
- raise MalformedAttributeContentError.new(e.message, 'columns', @variables['columns'])
65
- end
66
-
67
- result = {}
68
- result[:header] = [@variables['columns'].raw_value.split(',')]
69
- result[:content] = content
70
-
71
- result = format_columns(result, @variables['format'])
72
- result = replace_values(result, @variables.select { |k, _v| k =~ /^replace_values_\d+/ })
73
- result = filter_columns(result, @variables['filter_columns'])
74
- if @variables['filter_column']
75
- @report.logger.warn("DEPRECATED: Call of no longer supported function 'filter_column' has been found. Rename to 'filter_columns'")
76
- result = filter_columns(result, @variables['filter_column'])
77
- end
78
-
79
- @result = result[:content].map { |row| '| ' + row.map { |item| item.to_s.gsub('|', '\\|') }.join(' | ') }
80
- end
81
-
82
- private
83
-
84
- def url_parameters
85
- url_vars = {}
86
- url_vars['dashboardId'] = ::Grafana::Variable.new(@dashboard.id) if @dashboard
87
- url_vars['panelId'] = ::Grafana::Variable.new(@panel.id) if @panel
88
-
89
- url_vars.merge!(variables.select { |k, _v| k =~ /^(?:limit|dashboardId|panelId|query|state|folderId|dashboardQuery|dashboardTag)/ })
90
- url_vars['from'] = ::Grafana::Variable.new(@from) if @from
91
- url_vars['to'] = ::Grafana::Variable.new(@to) if @to
92
- url_params = URI.encode_www_form(url_vars.map { |k, v| [k, v.raw_value.to_s] })
93
- return '' if url_params.empty?
94
-
95
- '?' + url_params
96
- end
97
- end
98
- end
99
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'query_mixin'
4
+
5
+ module GrafanaReporter
6
+ module Asciidoctor
7
+ # This class is used to query alerts from grafana.
8
+ class AlertsTableQuery < Grafana::AbstractQuery
9
+ include QueryMixin
10
+
11
+ # @option opts [Grafana::Dashboard] :dashboard dashboard, if alerts shall be filtered for a dashboard
12
+ # @option opts [Grafnaa::Oanel] :panel panel, if alerts shall be filtered for a panel
13
+ def initialize(opts = {})
14
+ super()
15
+
16
+ @dashboard = opts[:dashboard]
17
+ @panel = opts[:panel]
18
+ @dashboard = @panel.dashboard if @panel
19
+
20
+ extract_dashboard_variables(@dashboard) if @dashboard
21
+ end
22
+
23
+ # @return [String] URL for querying alerts
24
+ def url
25
+ "/api/alerts#{url_parameters}"
26
+ end
27
+
28
+ # @return [Hash] empty hash object
29
+ def request
30
+ {}
31
+ end
32
+
33
+ # Check if mandatory {Grafana::Variable} +columns+ is specified in variables.
34
+ #
35
+ # The value of the +columns+ variable has to be a comma separated list of column titles, which
36
+ # need to be included in the following list:
37
+ # - limit
38
+ # - dashboardId
39
+ # - panelId
40
+ # - query
41
+ # - state
42
+ # - folderId
43
+ # - dashboardQuery
44
+ # - dashboardTag
45
+ # @return [void]
46
+ def pre_process(_grafana)
47
+ raise MissingMandatoryAttributeError, 'columns' unless @variables['columns']
48
+
49
+ @from = translate_date(@from, @variables['grafana-report-timestamp'], false, @variables['from_timezone'] ||
50
+ @variables['grafana_default_from_timezone'])
51
+ @to = translate_date(@to, @variables['grafana-report-timestamp'], true, @variables['to_timezone'] ||
52
+ @variables['grafana_default_to_timezone'])
53
+ end
54
+
55
+ # Filter the query result for the given columns and sets the result in the preformatted SQL
56
+ # result stlye.
57
+
58
+ # Additionally it applies {QueryMixin#format_columns}, {QueryMixin#replace_values} and
59
+ # {QueryMixin#filter_columns}.
60
+ # @return [void]
61
+ def post_process
62
+ # extract data from returned json
63
+ result = JSON.parse(@result.body)
64
+ content = []
65
+ begin
66
+ result.each { |item| content << item.fetch_values(*@variables['columns'].raw_value.split(',')) }
67
+ rescue KeyError => e
68
+ raise MalformedAttributeContentError.new(e.message, 'columns', @variables['columns'])
69
+ end
70
+
71
+ result = {}
72
+ result[:header] = [@variables['columns'].raw_value.split(',')]
73
+ result[:content] = content
74
+
75
+ result = format_columns(result, @variables['format'])
76
+ result = replace_values(result, @variables.select { |k, _v| k =~ /^replace_values_\d+/ })
77
+ result = filter_columns(result, @variables['filter_columns'])
78
+
79
+ @result = result[:content].map { |row| "| #{row.map { |item| item.to_s.gsub('|', '\\|') }.join(' | ')}" }
80
+ end
81
+
82
+ private
83
+
84
+ def url_parameters
85
+ url_vars = {}
86
+ url_vars['dashboardId'] = ::Grafana::Variable.new(@dashboard.id) if @dashboard
87
+ url_vars['panelId'] = ::Grafana::Variable.new(@panel.id) if @panel
88
+
89
+ url_vars.merge!(variables.select do |k, _v|
90
+ k =~ /^(?:limit|dashboardId|panelId|query|state|folderId|dashboardQuery|dashboardTag)/x
91
+ end)
92
+ url_vars['from'] = ::Grafana::Variable.new(@from) if @from
93
+ url_vars['to'] = ::Grafana::Variable.new(@to) if @to
94
+ url_params = URI.encode_www_form(url_vars.map { |k, v| [k, v.raw_value.to_s] })
95
+ return '' if url_params.empty?
96
+
97
+ "?#{url_params}"
98
+ end
99
+ end
100
+ end
101
+ end