ruby-grafana-reporter 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +39 -21
- data/lib/VERSION.rb +2 -2
- data/lib/grafana/dashboard.rb +1 -2
- data/lib/grafana/grafana_alerts_datasource.rb +1 -0
- data/lib/grafana/variable.rb +1 -1
- data/lib/grafana_reporter/application/application.rb +1 -0
- data/lib/grafana_reporter/application/webservice.rb +62 -24
- data/lib/grafana_reporter/asciidoctor/panel_query_table_include_processor.rb +1 -0
- data/lib/grafana_reporter/asciidoctor/panel_query_value_inline_macro.rb +1 -0
- data/lib/grafana_reporter/asciidoctor/report.rb +9 -16
- data/lib/grafana_reporter/asciidoctor/sql_table_include_processor.rb +2 -0
- data/lib/grafana_reporter/asciidoctor/sql_value_inline_macro.rb +2 -0
- data/lib/grafana_reporter/console_configuration_wizard.rb +3 -1
- data/lib/ruby_grafana_reporter.rb +2 -4
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 57a3edcb1aa6c41ed2a26688dc6a8a80fba3b9169aa79ee061eeb2d2c0df1cb9
|
4
|
+
data.tar.gz: 897ae84eb2672b645528e118ff7107e2fab7a4f334f63a4c87bb29a020cf93a8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b3889cace3794d62bfd50978a24c4eb39cda1148398a4bed8c4354d5c51ad339faa7da7a81b4f001cb29c383ac98334494520a9cbeec2b44019dcafae883bfdb
|
7
|
+
data.tar.gz: 9a7fbf743f0e48e114deaa1d518ba993ea0318229ab391d90481bad43f859b20fca9f8ffaafe6799729a81ace22673d4904797963c776cf9a22c942289f35c23
|
data/README.md
CHANGED
@@ -17,26 +17,17 @@ Reporting Service for Grafana
|
|
17
17
|
* [Installation](#installation)
|
18
18
|
* [Grafana integration](#grafana-integration)
|
19
19
|
* [Advanced information](#advanced-information)
|
20
|
-
* [
|
20
|
+
* [Use grafana variables in templates](#use-grafana-variables-in-templates)
|
21
|
+
* [Webservice endpoints](#webservice-endpoints)
|
22
|
+
* [API endpoints](#api-endpoints)
|
21
23
|
* [Using ERB templates](#using-erb-templates)
|
22
24
|
* [Using webhooks](#using-webhooks)
|
23
|
-
* [Developing your own plugin](#developing-your-own-plugin)
|
25
|
+
* [Developing your own datasource plugin](#developing-your-own-datasource-plugin)
|
24
26
|
* [Roadmap](#roadmap)
|
25
27
|
* [Contributing](#contributing)
|
26
28
|
* [Licensing](#licensing)
|
27
29
|
* [Acknowledgements](#acknowledgements)
|
28
30
|
|
29
|
-
## Your support is appreciated!
|
30
|
-
|
31
|
-
Hey there! I provide you this software free of charge. I have already
|
32
|
-
spend a lot of my private time in developing, maintaining and supporting it.
|
33
|
-
|
34
|
-
If you enjoy my work, feel free to
|
35
|
-
|
36
|
-
[](https://ko-fi.com/divinity666)
|
37
|
-
|
38
|
-
Thanks for your support and keeping this project alive!
|
39
|
-
|
40
31
|
## About the project
|
41
32
|
|
42
33
|
[Grafana](https://github.com/grafana/grafana) is a great tool for monitoring and
|
@@ -62,11 +53,12 @@ By default (an extended version of) Asciidoctor is enabled as template language.
|
|
62
53
|
|
63
54
|
### Features
|
64
55
|
|
65
|
-
*
|
66
|
-
|
67
|
-
|
68
|
-
*
|
69
|
-
|
56
|
+
* Use Asciidoctor or ERB template language for defining templates
|
57
|
+
* Render your templates as PDF (default), HTML, CSV, text files and any other format
|
58
|
+
you can think of
|
59
|
+
* Create reports containing data from multiple [grafana](https://github.com/grafana/grafana)
|
60
|
+
dashboards or even multiple grafana installations
|
61
|
+
* Easy-to-use configuration wizard including automated demo report creation
|
70
62
|
* Include dynamic content from grafana (find here a reference for all
|
71
63
|
[asciidcotor reporter calls](FUNCTION_CALLS.md)):
|
72
64
|
* panels as images
|
@@ -77,8 +69,7 @@ database queries
|
|
77
69
|
* webservice to be called directly from grafana
|
78
70
|
* standalone command line tool, e.g. to be automated with `cron` or `bash` scrips
|
79
71
|
* microservice from standard asciidoctor docker container without any dependencies
|
80
|
-
*
|
81
|
-
configuration file) to combine them with your services
|
72
|
+
* Integrate it in your toolchain with webhooks and API calls
|
82
73
|
* Solid as a rock - no matter if you do mistakes in your configuration or grafana does no
|
83
74
|
longer match templates: the ruby-grafana-reporter webservice will always return properly.
|
84
75
|
* Full [API documentation](https://rubydoc.info/gems/ruby-grafana-reporter) available
|
@@ -234,6 +225,21 @@ The main endpoint to call for report generation is configured in the previous ch
|
|
234
225
|
|
235
226
|
However, if you would like to see, currently running report generations and previously generated reports, you may want to call the endpoint `/overview`.
|
236
227
|
|
228
|
+
### API endpoints
|
229
|
+
|
230
|
+
If you want to call the reporter programatically, you might want to get machine
|
231
|
+
usable endpoints. Therefore the following API endpoints are available:
|
232
|
+
|
233
|
+
POST /api/v1/render
|
234
|
+
GET /api/v1/status
|
235
|
+
DELETE /api/v1/cancel
|
236
|
+
|
237
|
+
Those endpoints have to be called with the same URL parameters, as the webservice
|
238
|
+
endpoints.
|
239
|
+
|
240
|
+
If you want to receive the report itself, you'll have to use the `/view_report`
|
241
|
+
call from the webservice endpoints.
|
242
|
+
|
237
243
|
### Using ERB templates
|
238
244
|
|
239
245
|
By default the configuration wizard will setup the reporter with the asciidoctor
|
@@ -280,6 +286,12 @@ grafana-reporter:
|
|
280
286
|
callbacks:
|
281
287
|
all:
|
282
288
|
- http://<<your_callback_url>>
|
289
|
+
on_before_create:
|
290
|
+
- http://<<your_callback_url>>
|
291
|
+
on_after_cancel:
|
292
|
+
- http://<<your_callback_url>>
|
293
|
+
on_after_finish:
|
294
|
+
- http://<<your_callback_url>>
|
283
295
|
````
|
284
296
|
|
285
297
|
Remember to restart the reporter, if it is running as a webservice.
|
@@ -288,7 +300,7 @@ After having done so, your callback url will be called for each event with
|
|
288
300
|
a JSON body including all necessary information of the report. For details see
|
289
301
|
[callback](https://rubydoc.info/gems/ruby-grafana-reporter/GrafanaReporter/ReportWebhook#callback-instance_method).
|
290
302
|
|
291
|
-
### Developing your own plugin
|
303
|
+
### Developing your own datasource plugin
|
292
304
|
|
293
305
|
The reporter is designed to allow easy integration of your own plugins,
|
294
306
|
without having to modify the reporter base source on github (or anywhere
|
@@ -372,6 +384,12 @@ This is just a collection of things, I am heading for in future, without a sched
|
|
372
384
|
If you'd like to contribute, please fork the repository and use a feature
|
373
385
|
branch. Pull requests are warmly welcome.
|
374
386
|
|
387
|
+
If you enjoy my work, and can't support with code contributions, feel free to
|
388
|
+
|
389
|
+
[](https://ko-fi.com/divinity666)
|
390
|
+
|
391
|
+
Thanks for your support and keeping this project alive!
|
392
|
+
|
375
393
|
## Licensing
|
376
394
|
|
377
395
|
The code in this project is licensed under MIT license.
|
data/lib/VERSION.rb
CHANGED
data/lib/grafana/dashboard.rb
CHANGED
data/lib/grafana/variable.rb
CHANGED
@@ -240,7 +240,7 @@ module Grafana
|
|
240
240
|
if !@config['current'].nil?
|
241
241
|
self.raw_value = @config['current']['value']
|
242
242
|
else
|
243
|
-
raise GrafanaError.new("
|
243
|
+
raise GrafanaError.new("Dashboard variable with type '#{@config['type']}' and name '#{@config['name']}' cannot be handled properly by the reporter in queries. Check your resulting report and raise a ticket on github if you face issues.")
|
244
244
|
end
|
245
245
|
end
|
246
246
|
end
|
@@ -134,6 +134,7 @@ module GrafanaReporter
|
|
134
134
|
begin
|
135
135
|
template_ext = config.report_class.default_template_extension
|
136
136
|
report_ext = config.report_class.default_result_extension
|
137
|
+
report_ext = 'zip' if config.default_document_attributes["convert-backend"] != "pdf" and not config.default_document_attributes["convert-backend"].nil?
|
137
138
|
default_to_file = File.basename(config.template.to_s.gsub(/(?:\.#{template_ext})?$/, ".#{report_ext}"))
|
138
139
|
|
139
140
|
to_file = config.to_file
|
@@ -144,27 +144,56 @@ module GrafanaReporter
|
|
144
144
|
attrs[k] = v.length == 1 ? v[0] : v
|
145
145
|
end
|
146
146
|
|
147
|
-
|
148
|
-
|
149
|
-
return render_report(attrs)
|
147
|
+
parsed_url = request.split("\r\n")[0].match(%r{(?<verb>GET|POST|DELETE) (?<subpath>/.*?)(?:api/v1/(?<api_action>render|status|cancel))?(?<html_action>render|overview|view_report|cancel_report|view_log)?[ ?]})
|
148
|
+
parsed_url = {} if not parsed_url
|
150
149
|
|
151
|
-
|
150
|
+
case parsed_url
|
151
|
+
# API calls
|
152
|
+
when -> (h) { h['verb'] == 'POST' && h['api_action'] == 'render' }
|
153
|
+
return render_report(attrs, parsed_url['subpath'], true)
|
154
|
+
|
155
|
+
when -> (h) { h['verb'] == 'GET' && h['api_action'] == 'status' }
|
156
|
+
return report_status(attrs)
|
157
|
+
|
158
|
+
when -> (h) { h['verb'] == 'DELETE' && h['api_action'] == 'cancel' }
|
159
|
+
return cancel_report(attrs, parsed_url['subpath'], true)
|
160
|
+
|
161
|
+
# HTML calls
|
162
|
+
when -> (h) { h['verb'] == 'GET' && h['html_action'] == 'render' }
|
163
|
+
return render_report(attrs, parsed_url['subpath'])
|
164
|
+
|
165
|
+
when -> (h) { h['verb'] == 'GET' && h['html_action'] == 'overview' }
|
152
166
|
# show overview for current reports
|
153
|
-
return get_reports_status_as_html(@reports)
|
167
|
+
return get_reports_status_as_html(@reports, parsed_url['subpath'])
|
154
168
|
|
155
|
-
when
|
156
|
-
return view_report(attrs)
|
169
|
+
when -> (h) { h['verb'] == 'GET' && h['html_action'] == 'view_report' }
|
170
|
+
return view_report(attrs, parsed_url['subpath'])
|
157
171
|
|
158
|
-
when
|
159
|
-
return cancel_report(attrs)
|
172
|
+
when -> (h) { h['verb'] == 'GET' && h['html_action'] == 'cancel_report' }
|
173
|
+
return cancel_report(attrs, parsed_url['subpath'])
|
160
174
|
|
161
|
-
when
|
175
|
+
when -> (h) { h['verb'] == 'GET' && h['html_action'] == 'view_log' }
|
162
176
|
return view_log(attrs)
|
163
177
|
end
|
164
178
|
|
165
179
|
raise WebserviceUnknownPathError, request.split("\r\n")[0]
|
166
180
|
end
|
167
181
|
|
182
|
+
def report_status(attrs)
|
183
|
+
report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
|
184
|
+
raise WebserviceGeneralRenderingError, 'report_status has been called without valid id' if report.nil?
|
185
|
+
|
186
|
+
response = {
|
187
|
+
report_id: report.object_id,
|
188
|
+
progress: report.progress,
|
189
|
+
state: report.status,
|
190
|
+
done: report.done,
|
191
|
+
execution_time: report.execution_time
|
192
|
+
}
|
193
|
+
|
194
|
+
http_response(200, 'OK', JSON.generate(response), "Content-Type": "application/json")
|
195
|
+
end
|
196
|
+
|
168
197
|
def view_log(attrs)
|
169
198
|
# view report if already available, or show status view
|
170
199
|
report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
|
@@ -175,7 +204,7 @@ module GrafanaReporter
|
|
175
204
|
http_response(200, 'OK', content, "Content-Type": 'text/plain')
|
176
205
|
end
|
177
206
|
|
178
|
-
def cancel_report(attrs)
|
207
|
+
def cancel_report(attrs, subpath, as_json=false)
|
179
208
|
# view report if already available, or show status view
|
180
209
|
report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
|
181
210
|
raise WebserviceGeneralRenderingError, 'cancel_report has been called without valid id' if report.nil?
|
@@ -183,28 +212,34 @@ module GrafanaReporter
|
|
183
212
|
report.cancel! unless report.done
|
184
213
|
|
185
214
|
# redirect to view_report page
|
186
|
-
http_response(302, 'Found', nil, Location: "
|
215
|
+
return http_response(302, 'Found', nil, Location: "#{subpath}view_report?report_id=#{report.object_id}") if not as_json
|
216
|
+
|
217
|
+
http_response(200, 'OK', nil)
|
187
218
|
end
|
188
219
|
|
189
|
-
def view_report(attrs)
|
220
|
+
def view_report(attrs, subpath)
|
190
221
|
# view report if already available, or show status view
|
191
222
|
report = @reports.select { |r| r.object_id.to_s == attrs['report_id'].to_s }.first
|
192
223
|
raise WebserviceGeneralRenderingError, 'view_report has been called without valid id' if report.nil?
|
193
224
|
|
194
225
|
# show report status
|
195
|
-
return get_reports_status_as_html([report]) if !report.done || !report.error.empty?
|
226
|
+
return get_reports_status_as_html([report], subpath) if !report.done || !report.error.empty?
|
196
227
|
|
197
228
|
# provide report
|
198
|
-
@logger.debug("Returning
|
229
|
+
@logger.debug("Returning report file #{report.path}")
|
199
230
|
content = File.read(report.path, mode: 'rb')
|
200
231
|
return http_response(200, 'OK', content, "Content-Type": 'application/pdf') if content.start_with?('%PDF')
|
201
232
|
|
233
|
+
return http_response(200, 'OK', content, "Content-Type": 'application/octet-stream',
|
234
|
+
"Content-Disposition": 'attachment; '\
|
235
|
+
"filename=report_#{attrs['report_id']}.zip") if content.start_with?('PK')
|
236
|
+
|
202
237
|
http_response(200, 'OK', content, "Content-Type": 'application/octet-stream',
|
203
238
|
"Content-Disposition": 'attachment; '\
|
204
|
-
|
239
|
+
"filename=report_#{attrs['report_id']}.#{report.class.default_result_extension}")
|
205
240
|
end
|
206
241
|
|
207
|
-
def render_report(attrs)
|
242
|
+
def render_report(attrs, subpath, as_json=false)
|
208
243
|
# build report
|
209
244
|
template_file = "#{@config.templates_folder}#{attrs['var-template']}"
|
210
245
|
|
@@ -222,10 +257,13 @@ module GrafanaReporter
|
|
222
257
|
end
|
223
258
|
@reports << report
|
224
259
|
|
225
|
-
http_response(302, 'Found', nil, Location: "
|
260
|
+
return http_response(302, 'Found', nil, Location: "#{subpath}view_report?report_id=#{report.object_id}") if not as_json
|
261
|
+
|
262
|
+
response = {report_id: report.object_id}
|
263
|
+
return http_response(200, 'OK', JSON.generate(response))
|
226
264
|
end
|
227
265
|
|
228
|
-
def get_reports_status_as_html(reports)
|
266
|
+
def get_reports_status_as_html(reports, subpath)
|
229
267
|
i = reports.length
|
230
268
|
|
231
269
|
# TODO: make reporter HTML results customizable
|
@@ -245,14 +283,14 @@ module GrafanaReporter
|
|
245
283
|
<td><%= report.status %> (<%= (report.progress * 100).to_i %>%)</td>
|
246
284
|
<td><%= report.error.join('<br>') %></td>
|
247
285
|
<td><% if !report.done && !report.cancel %>
|
248
|
-
<a href="
|
286
|
+
<a href="<%= subpath %>cancel_report?report_id=<%= report.object_id %>">Cancel</a>
|
249
287
|
<% end %>
|
250
288
|
|
251
289
|
<% if (report.status == 'finished') || (report.status == 'cancelled') %>
|
252
|
-
<a href="
|
290
|
+
<a href="<%= subpath %>view_report?report_id=<%= report.object_id %>">View</a>
|
253
291
|
<% end %>
|
254
292
|
|
255
|
-
<a href="
|
293
|
+
<a href="<%= subpath %>view_log?report_id=<%= report.object_id %>">Log</a></td></tr>
|
256
294
|
<% end.join('') %>
|
257
295
|
<tbody>
|
258
296
|
</table>
|
@@ -267,8 +305,8 @@ module GrafanaReporter
|
|
267
305
|
end
|
268
306
|
|
269
307
|
def http_response(code, text, body, opts = {})
|
270
|
-
"HTTP/1.1 #{code} #{text}\r\n#{opts.map { |k, v| "#{k}: #{v}" }.join("\r\n")}"\
|
271
|
-
"#{body ? "
|
308
|
+
"HTTP/1.1 #{code} #{text}\r\n#{opts.map { |k, v| "#{k}: #{v}" }.join("\r\n")}#{opts.length > 0 ? "\r\n" : ""}"\
|
309
|
+
"#{body ? "Content-Length: #{body.to_s.bytesize}" : ''}\r\n\r\n#{body}"
|
272
310
|
end
|
273
311
|
end
|
274
312
|
end
|
@@ -41,36 +41,29 @@ module GrafanaReporter
|
|
41
41
|
::Asciidoctor.convert_file(@template, extension_registry: registry, backend: attrs['convert-backend'],
|
42
42
|
to_file: path, attributes: attrs, header_footer: true)
|
43
43
|
|
44
|
-
# store report including
|
44
|
+
# store report including all images as ZIP file, if the result is not a PDF
|
45
45
|
if attrs['convert-backend'] != 'pdf'
|
46
46
|
# build zip file
|
47
|
-
zip_file = Tempfile.new('gf_zip')
|
48
47
|
buffer = Zip::OutputStream.write_buffer do |zipfile|
|
49
48
|
# add report file
|
50
|
-
zipfile.put_next_entry("#{path.gsub(@config.reports_folder, '')}.#{attrs['convert-backend']}")
|
51
|
-
zipfile.write File.
|
49
|
+
zipfile.put_next_entry("#{path.gsub(@config.reports_folder, '').gsub(/\.[\w\d]+$/, '')}.#{attrs['convert-backend']}")
|
50
|
+
zipfile.write File.open(path, 'rb') { |f| f.read }
|
52
51
|
|
53
52
|
# add image files
|
54
53
|
@image_files.each do |file|
|
55
54
|
zipfile.put_next_entry(file.path.gsub(@config.images_folder, ''))
|
56
|
-
zipfile.write File.
|
55
|
+
zipfile.write File.open(file.path, 'rb') { |f| f.read }
|
57
56
|
end
|
58
57
|
end
|
59
|
-
File.open(zip_file, 'wb') do |f|
|
60
|
-
f.write buffer.string
|
61
|
-
end
|
62
58
|
|
63
|
-
#
|
64
|
-
zip_file.rewind
|
59
|
+
# write zip file
|
65
60
|
begin
|
66
|
-
File.
|
61
|
+
File.open(path, 'wb') do |f|
|
62
|
+
f.write buffer.string
|
63
|
+
end
|
67
64
|
rescue StandardError => e
|
68
|
-
logger.fatal("Could not overwrite
|
65
|
+
logger.fatal("Could not overwrite file '#{path}' with zipped file. (#{e.message}).")
|
69
66
|
end
|
70
|
-
|
71
|
-
# cleanup temporary zip file
|
72
|
-
zip_file.close
|
73
|
-
zip_file.unlink
|
74
67
|
end
|
75
68
|
|
76
69
|
clean_image_files
|
@@ -84,6 +84,8 @@ module GrafanaReporter
|
|
84
84
|
end
|
85
85
|
end
|
86
86
|
return nil unless ref_id
|
87
|
+
# FIXME this filters out e.g. prometheus in demo reports, as the query method returns a Hash instead of a string
|
88
|
+
return nil unless panel.query(ref_id).is_a?(String)
|
87
89
|
|
88
90
|
"|===\ninclude::grafana_sql_table:#{panel.dashboard.grafana.datasource_by_model_entry(panel.model['datasource']).id}"\
|
89
91
|
"[sql=\"#{panel.query(ref_id).gsub(/"/, '\"').gsub("\r\n", ' ').gsub("\n", ' ').gsub(/\\/, '\\\\')}\",filter_columns=\"time\","\
|
@@ -89,6 +89,8 @@ module GrafanaReporter
|
|
89
89
|
end
|
90
90
|
end
|
91
91
|
return nil unless ref_id
|
92
|
+
# FIXME this filters out e.g. prometheus in demo reports, as the query method returns a Hash instead of a string
|
93
|
+
return nil unless panel.query(ref_id).is_a?(String)
|
92
94
|
|
93
95
|
"grafana_sql_value:#{panel.dashboard.grafana.datasource_by_model_entry(panel.model['datasource']).id}"\
|
94
96
|
"[sql=\"#{panel.query(ref_id).gsub(/"/, '\"').gsub("\r\n", ' ').gsub("\n", ' ').gsub(/\\/, '\\\\')}\",from=\"now-1h\","\
|
@@ -319,8 +319,10 @@ default-document-attributes:
|
|
319
319
|
end
|
320
320
|
|
321
321
|
def user_input(text, default)
|
322
|
+
$stdout.sync = true
|
322
323
|
print "#{text} [#{default}]: "
|
323
|
-
|
324
|
+
$stdout.sync = false
|
325
|
+
input = gets.gsub(/[\n\r]*$/, '')
|
324
326
|
input = default if input.empty?
|
325
327
|
input
|
326
328
|
end
|
@@ -1,8 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'rubygems'
|
4
|
-
require 'rubygems/name_tuple'
|
5
|
-
require 'rubygems/ext'
|
3
|
+
require 'rubygems' # for OCRAN
|
6
4
|
require 'net/http'
|
7
5
|
require 'fileutils'
|
8
6
|
require 'yaml'
|
@@ -16,7 +14,7 @@ require 'date'
|
|
16
14
|
require 'time'
|
17
15
|
require 'logger'
|
18
16
|
require 'asciidoctor'
|
19
|
-
require 'asciidoctor/
|
17
|
+
require 'asciidoctor/converter/html5' # for OCRAN
|
20
18
|
require 'asciidoctor-pdf'
|
21
19
|
require 'zip'
|
22
20
|
require_relative 'VERSION'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby-grafana-reporter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.9.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Christian Kohlmeyer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-08-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: asciidoctor
|