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.
- checksums.yaml +4 -4
- data/LICENSE +0 -0
- data/README.md +95 -173
- data/bin/ruby-grafana-reporter +5 -0
- data/lib/VERSION.rb +5 -3
- data/lib/grafana/abstract_panel_query.rb +22 -20
- data/lib/grafana/abstract_query.rb +132 -127
- data/lib/grafana/abstract_sql_query.rb +51 -42
- data/lib/grafana/dashboard.rb +77 -66
- data/lib/grafana/errors.rb +66 -61
- data/lib/grafana/grafana.rb +133 -131
- data/lib/grafana/panel.rb +41 -39
- data/lib/grafana/panel_image_query.rb +52 -49
- data/lib/grafana/variable.rb +217 -259
- data/lib/grafana_reporter/abstract_report.rb +112 -109
- data/lib/grafana_reporter/application/application.rb +158 -229
- data/lib/grafana_reporter/application/errors.rb +33 -30
- data/lib/grafana_reporter/application/webservice.rb +230 -0
- data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +101 -99
- data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +96 -96
- data/lib/grafana_reporter/asciidoctor/errors.rb +40 -37
- data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +92 -86
- data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +91 -86
- data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +69 -67
- data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +68 -65
- data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +61 -58
- data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +78 -75
- data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +73 -70
- data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +20 -18
- data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +43 -41
- data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +30 -202
- data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +70 -67
- data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +66 -65
- data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +88 -57
- data/lib/grafana_reporter/asciidoctor/help.rb +435 -0
- data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +36 -32
- data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +28 -23
- data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +44 -43
- data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +40 -36
- data/lib/grafana_reporter/asciidoctor/query_mixin.rb +312 -309
- data/lib/grafana_reporter/asciidoctor/report.rb +179 -159
- data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +42 -34
- data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +44 -32
- data/lib/grafana_reporter/configuration.rb +304 -326
- data/lib/grafana_reporter/console_configuration_wizard.rb +269 -0
- data/lib/grafana_reporter/errors.rb +48 -38
- data/lib/grafana_reporter/logger/two_way_logger.rb +58 -52
- data/lib/ruby-grafana-reporter.rb +32 -27
- metadata +116 -16
@@ -1,30 +1,33 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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> " : ''}"\
|
215
|
+
"#{(report.status == 'finished') || (report.status == 'cancelled') ? "<a href=\"/view_report?report_id=#{report.object_id}\">View</a> " : ' '}"\
|
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
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
@dashboard =
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
#
|
34
|
-
#
|
35
|
-
#
|
36
|
-
#
|
37
|
-
# -
|
38
|
-
# -
|
39
|
-
# -
|
40
|
-
# -
|
41
|
-
# -
|
42
|
-
# -
|
43
|
-
#
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
#
|
56
|
-
#
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
result =
|
72
|
-
result =
|
73
|
-
result =
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
@result = result[:content].map { |row|
|
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
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
'
|
96
|
-
|
97
|
-
|
98
|
-
|
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
|