ruby-grafana-reporter 0.1.6

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +248 -0
  4. data/lib/VERSION.rb +3 -0
  5. data/lib/grafana/abstract_panel_query.rb +20 -0
  6. data/lib/grafana/abstract_query.rb +127 -0
  7. data/lib/grafana/abstract_sql_query.rb +42 -0
  8. data/lib/grafana/dashboard.rb +66 -0
  9. data/lib/grafana/errors.rb +61 -0
  10. data/lib/grafana/grafana.rb +131 -0
  11. data/lib/grafana/panel.rb +39 -0
  12. data/lib/grafana/panel_image_query.rb +49 -0
  13. data/lib/grafana/variable.rb +259 -0
  14. data/lib/grafana_reporter/abstract_report.rb +109 -0
  15. data/lib/grafana_reporter/application/application.rb +229 -0
  16. data/lib/grafana_reporter/application/errors.rb +30 -0
  17. data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +99 -0
  18. data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +96 -0
  19. data/lib/grafana_reporter/asciidoctor/errors.rb +37 -0
  20. data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +86 -0
  21. data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +86 -0
  22. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +67 -0
  23. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +65 -0
  24. data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +58 -0
  25. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +75 -0
  26. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +70 -0
  27. data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +18 -0
  28. data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +41 -0
  29. data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +202 -0
  30. data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +67 -0
  31. data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +65 -0
  32. data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +57 -0
  33. data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +32 -0
  34. data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +23 -0
  35. data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +43 -0
  36. data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +36 -0
  37. data/lib/grafana_reporter/asciidoctor/query_mixin.rb +309 -0
  38. data/lib/grafana_reporter/asciidoctor/report.rb +159 -0
  39. data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +34 -0
  40. data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +32 -0
  41. data/lib/grafana_reporter/configuration.rb +326 -0
  42. data/lib/grafana_reporter/errors.rb +38 -0
  43. data/lib/grafana_reporter/logger/two_way_logger.rb +52 -0
  44. data/lib/ruby-grafana-reporter.rb +27 -0
  45. metadata +88 -0
@@ -0,0 +1,109 @@
1
+ module GrafanaReporter
2
+ # @abstract Override {#create_report} and {#progress}.
3
+ #
4
+ # This class is used to build a report on basis of a given configuration and
5
+ # template.
6
+ #
7
+ # Objects of this class are also stored in {Application::Application}, unless
8
+ # the retention time is over.
9
+ class AbstractReport
10
+ # @return [String] path to the template
11
+ attr_reader :template
12
+
13
+ # @return [Time] time, when the report generation started
14
+ attr_reader :start_time
15
+
16
+ # @return [Time] time, when the report generation ended
17
+ attr_reader :end_time
18
+
19
+ # @return [Logger] logger object used during report generation
20
+ attr_reader :logger
21
+
22
+ # @return [Boolean] true, if the report is or shall be cancelled
23
+ attr_reader :cancel
24
+
25
+ # @return [Boolen] true, if the report generation is finished (successfull or not)
26
+ attr_reader :done
27
+
28
+ # @param config [Configuration] configuration object
29
+ # @param template [String] path to the template to be used
30
+ # @param destination_file_or_path [String or File] path to the destination report or file object to use
31
+ # @param custom_attributes [Hash] custom attributes, which shall be merged with priority over the configuration
32
+ def initialize(config, template, destination_file_or_path = nil, custom_attributes = {})
33
+ @config = config
34
+ @logger = Logger::TwoWayDelegateLogger.new
35
+ @logger.additional_logger = @config.logger
36
+ @done = false
37
+ @template = template
38
+ @destination_file_or_path = destination_file_or_path
39
+ @custom_attributes = custom_attributes
40
+ @start_time = nil
41
+ @end_time = nil
42
+ @cancel = false
43
+ raise MissingTemplateError, @template.to_s unless File.exist?(@template.to_s)
44
+ end
45
+
46
+ # Call to request cancelling the report generation.
47
+ # @return [void]
48
+ def cancel!
49
+ @cancel = true
50
+ logger.info('Cancelling report generation invoked.')
51
+ end
52
+
53
+ # @return [String] path to the report destination file
54
+ def path
55
+ @destination_file_or_path.respond_to?(:path) ? @destination_file_or_path.path : @destination_file_or_path
56
+ end
57
+
58
+ # Deletes the report file object.
59
+ # @return [void]
60
+ def delete_file
61
+ if @destination_file_or_path.is_a?(Tempfile)
62
+ @destination_file_or_path.unlink
63
+ elsif @destination_file_or_path.is_a?(File)
64
+ @destination_file_or_path.delete
65
+ end
66
+ @destination_file_or_path = nil
67
+ end
68
+
69
+ # @return [Float] time in seconds, that the report generation took
70
+ def execution_time
71
+ return nil if start_time.nil?
72
+ return end_time - start_time unless end_time.nil?
73
+
74
+ Time.now - start_time
75
+ end
76
+
77
+ # @return [Array] error messages during report generation.
78
+ def error
79
+ @error || []
80
+ end
81
+
82
+ # @return [String] status of the report, one of 'in progress', 'cancelled', 'died' or 'finished'.
83
+ def status
84
+ return 'cancelled' if done && cancel
85
+ return 'finished' if done && error.empty?
86
+ return 'died' if done && !error.empty?
87
+
88
+ 'in progress'
89
+ end
90
+
91
+ # @return [String] string containing all messages ([Logger::Severity::DEBUG]) of the logger during report generation.
92
+ def full_log
93
+ logger.internal_messages
94
+ end
95
+
96
+ # @abstract
97
+ # Is being called to start the report generation.
98
+ # @return [void]
99
+ def create_report
100
+ raise NotImplementedError
101
+ end
102
+
103
+ # @abstract
104
+ # @return [Integer] number between 0 and 100, representing the current progress of the report creation.
105
+ def progress
106
+ raise NotImplementedError
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,229 @@
1
+ module GrafanaReporter
2
+ # This module contains all classes, which are used by the grafana reporter
3
+ # application. The application is a set of classes, which allows to run the
4
+ # reporter in several ways.
5
+ #
6
+ # If you intend to use the reporter functionality, without the application,
7
+ # it might be helpful to not use the classes from here.
8
+ module Application
9
+ # This class contains the main application to run the grafana reporter.
10
+ #
11
+ # It can be run to test the grafana connection, render a single template
12
+ # or run as a service.
13
+ class Application
14
+ def initialize
15
+ @logger = ::Logger.new(STDERR, level: :unknown)
16
+ @reports = []
17
+ end
18
+
19
+ # Can be used to set a {Configuration} object to the application.
20
+ #
21
+ # This is mainly helpful in testing the application or in an
22
+ # integrated use.
23
+ # @param config {Configuration} configuration to be used by the application
24
+ # @return [void]
25
+ def config=(config)
26
+ @logger = config.logger || @logger
27
+ @config = config
28
+ end
29
+
30
+ # This is the main method, which is called, if the application is
31
+ # run in standalone mode.
32
+ # @param params [Array] normally the ARGV command line parameters
33
+ # @return [Integer] see {#run}
34
+ def configure_and_run(params = [])
35
+ config = GrafanaReporter::Configuration.new
36
+ config.logger.level = ::Logger::Severity::INFO
37
+ result = config.configure_by_command_line(params)
38
+ return result if result != 0
39
+
40
+ self.config = config
41
+ run
42
+ end
43
+
44
+ # Runs the application with the current set {Configuration} object.
45
+ # @return [Integer] value smaller than 0, if error. 0 if successfull
46
+ def run
47
+ begin
48
+ @config.validate
49
+ rescue ConfigurationError => e
50
+ puts e.message
51
+ return -2
52
+ end
53
+
54
+ case @config.mode
55
+ when Configuration::MODE_CONNECTION_TEST
56
+ res = Grafana::Grafana.new(@config.grafana_host(@config.test_instance), @config.grafana_api_key(@config.test_instance), logger: @logger).test_connection
57
+ puts res
58
+
59
+ when Configuration::MODE_SINGLE_RENDER
60
+ @config.report_class.new(@config, @config.template, @config.to_file).create_report
61
+
62
+ when Configuration::MODE_SERVICE
63
+ run_webserver
64
+ end
65
+ 0
66
+ end
67
+
68
+ private
69
+
70
+ def clean_outdated_temporary_reports
71
+ clean_time = Time.now - 60 * 60 * @config.report_retention
72
+ @reports.select { |report| report.done && clean_time > report.end_time }.each do |report|
73
+ @reports.delete(report).delete_file
74
+ end
75
+ end
76
+
77
+ def run_webserver
78
+ # start webserver
79
+ server = TCPServer.new(@config.webserver_port)
80
+ @logger.info("Server listening on port #{@config.webserver_port}...")
81
+
82
+ @progress_reporter = Thread.new {}
83
+
84
+ loop do
85
+ # step 1) accept incoming connection
86
+ socket = server.accept
87
+
88
+ # step 2) print the request headers (separated by a blank line e.g. \r\n)
89
+ request = ''
90
+ line = ''
91
+ begin
92
+ until line == "\r\n"
93
+ line = socket.readline
94
+ request += line
95
+ end
96
+ rescue EOFError => e
97
+ @logger.debug("Webserver EOFError: #{e.message}")
98
+ end
99
+
100
+ begin
101
+ response = handle_request(request)
102
+ socket.write response
103
+ rescue WebserviceUnknownPathError => e
104
+ @logger.debug(e.message)
105
+ socket.write http_response(404, '', e.message)
106
+ rescue MissingTemplateError => e
107
+ @logger.error(e.message)
108
+ socket.write http_response(400, 'Bad Request', e.message)
109
+ rescue WebserviceGeneralRenderingError => e
110
+ @logger.fatal(e.message)
111
+ socket.write http_response(400, 'Bad Request', e.message)
112
+ rescue StandardError => e
113
+ @logger.fatal(e.message)
114
+ socket.write http_response(400, 'Bad Request', e.message)
115
+ ensure
116
+ socket.close
117
+ end
118
+
119
+ unless @progress_reporter.alive?
120
+ @progress_reporter = Thread.new do
121
+ running_reports = @reports.reject(&:done)
122
+ until running_reports.empty?
123
+ @logger.info("#{running_reports.length} report(s) in progress: #{running_reports.map { |report| (report.progress * 100).to_i.to_s + '% (running ' + report.execution_time.to_i.to_s + ' secs)' }.join(', ')}") unless running_reports.empty?
124
+ sleep 5
125
+ running_reports = @reports.reject(&:done)
126
+ end
127
+ # puts "no more running reports - stopping to report progress"
128
+ end
129
+ end
130
+
131
+ clean_outdated_temporary_reports
132
+ end
133
+ end
134
+
135
+ def handle_request(request)
136
+ raise WebserviceUnknownPathError, request.split("\r\n")[0] if request.nil?
137
+ raise WebserviceUnknownPathError, request.split("\r\n")[0] if request.split("\r\n")[0].nil?
138
+
139
+ query_string = request.split("\r\n")[0].gsub(%r{(?:[^\?]+[\?])(.*)(?: HTTP/.*)$}, '\1')
140
+ query_parameters = CGI.parse(query_string)
141
+
142
+ @logger.debug("Received request: #{request.split("\r\n")[0]}")
143
+ @logger.debug('query_parameters: ' + query_parameters.to_s)
144
+
145
+ # read URL parameters
146
+ attrs = {}
147
+ query_parameters.each do |k, v|
148
+ attrs[k] = v.length == 1 ? v[0] : v
149
+ end
150
+
151
+ if request.split("\r\n")[0] =~ %r{^GET /render[\? ]}
152
+ # build report
153
+ template_file = @config.templates_folder.to_s + attrs['var-template'].to_s + '.adoc'
154
+
155
+ file = Tempfile.new('gf_pdf_', @config.reports_folder)
156
+ begin
157
+ FileUtils.chmod('+r', file.path)
158
+ rescue StandardError => e
159
+ @logger.debug("File permissions could not be set for #{file.path}: #{e.message}")
160
+ end
161
+
162
+ report = @config.report_class.new(@config, template_file, file, attrs)
163
+ Thread.new do
164
+ report.create_report
165
+ end
166
+ @reports << report
167
+
168
+ return http_response(302, 'Found', nil, Location: "/view_report?report_id=#{report.object_id}")
169
+
170
+ elsif request.split("\r\n")[0] =~ %r{^GET /overview[\? ]}
171
+ # show overview for current reports
172
+ return get_reports_status_as_html(@reports)
173
+
174
+ elsif request.split("\r\n")[0] =~ %r{^GET /view_report[\? ]}
175
+ # view report if already available, or show status view
176
+ report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
177
+ raise WebserviceGeneralRenderingError, 'view_report has been called without valid id' if report.nil?
178
+
179
+ # show report status
180
+ return get_reports_status_as_html([report]) if !report.done || !report.error.empty?
181
+
182
+ # provide report
183
+ @logger.debug("Returning PDF report at #{report.path}")
184
+ content = File.read(report.path)
185
+ return http_response(200, 'OK', content, "Content-Type": 'application/pdf') if content.start_with?("%PDF")
186
+ # TODO properly provide file as zip
187
+ return http_response(200, 'OK', content, "Content-Type": 'application/octet-stream', "Content-Disposition": "attachment; filename=report.zip")
188
+
189
+ elsif request.split("\r\n")[0] =~ %r{^GET /cancel_report[\? ]}
190
+ # view report if already available, or show status view
191
+ report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
192
+ raise WebserviceGeneralRenderingError, 'cancel_report has been called without valid id' if report.nil?
193
+
194
+ report.cancel! unless report.done
195
+
196
+ # redirect to view_report page
197
+ return http_response(302, 'Found', nil, Location: "/view_report?report_id=#{report.object_id}")
198
+
199
+ elsif request.split("\r\n")[0] =~ %r{^GET /view_log[\? ]}
200
+ # view report if already available, or show status view
201
+ report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
202
+ raise WebserviceGeneralRenderingError, 'view_log has been called without valid id' if report.nil?
203
+
204
+ content = report.full_log
205
+
206
+ return http_response(200, 'OK', content, "Content-Type": 'text/plain')
207
+ end
208
+
209
+ raise WebserviceUnknownPathError, request.split("\r\n")[0]
210
+ end
211
+
212
+ def get_reports_status_as_html(reports)
213
+ i = reports.length
214
+
215
+ content = '<html><head></head><body><table><thead><th>#</th><th>Start Time</th><th>End Time</th><th>Template</th><th>Execution time</th><th>Status</th><th>Error</th><th>Action</th></thead>' +
216
+ reports.reverse.map do |report|
217
+ "<tr><td>#{(i -= 1)}</td><td>#{report.start_time}</td><td>#{report.end_time}</td><td>#{report.template}</td><td>#{report.execution_time.to_i} secs</td><td>#{report.status} (#{(report.progress * 100).to_i}%)</td><td>#{report.error.join('<br>')}</td><td>#{!report.done && !report.cancel ? "<a href=\"/cancel_report?report_id=#{report.object_id}\">Cancel</a>&nbsp;" : ''}#{(report.status == 'finished') || (report.status == 'cancelled') ? "<a href=\"/view_report?report_id=#{report.object_id}\">View</a>&nbsp;" : '&nbsp;'}<a href=\"/view_log?report_id=#{report.object_id}\">Log</a></td></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")}#{body ? "\r\nContent-Length: #{body.to_s.bytesize}" : ''}\r\n\r\n#{body}"
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,99 @@
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