log_sense 1.5.2 → 1.6.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.org +27 -0
  3. data/Gemfile.lock +6 -4
  4. data/README.org +108 -34
  5. data/Rakefile +6 -6
  6. data/exe/log_sense +110 -39
  7. data/ip_locations/dbip-country-lite.sqlite3 +0 -0
  8. data/lib/log_sense/aggregator.rb +191 -0
  9. data/lib/log_sense/apache_aggregator.rb +122 -0
  10. data/lib/log_sense/apache_log_line_parser.rb +23 -21
  11. data/lib/log_sense/apache_log_parser.rb +15 -12
  12. data/lib/log_sense/apache_report_shaper.rb +309 -0
  13. data/lib/log_sense/emitter.rb +55 -553
  14. data/lib/log_sense/ip_locator.rb +24 -12
  15. data/lib/log_sense/options_checker.rb +24 -0
  16. data/lib/log_sense/options_parser.rb +81 -51
  17. data/lib/log_sense/rails_aggregator.rb +69 -0
  18. data/lib/log_sense/rails_log_parser.rb +82 -68
  19. data/lib/log_sense/rails_report_shaper.rb +183 -0
  20. data/lib/log_sense/report_shaper.rb +105 -0
  21. data/lib/log_sense/templates/_cdn_links.html.erb +11 -0
  22. data/lib/log_sense/templates/_command_invocation.html.erb +4 -0
  23. data/lib/log_sense/templates/_log_structure.html.erb +7 -1
  24. data/lib/log_sense/templates/_output_table.html.erb +6 -2
  25. data/lib/log_sense/templates/_rails.css.erb +7 -0
  26. data/lib/log_sense/templates/_summary.html.erb +9 -7
  27. data/lib/log_sense/templates/_summary.txt.erb +2 -2
  28. data/lib/log_sense/templates/{rails.html.erb → report_html.erb} +19 -37
  29. data/lib/log_sense/templates/{apache.txt.erb → report_txt.erb} +1 -1
  30. data/lib/log_sense/version.rb +1 -1
  31. data/lib/log_sense.rb +19 -9
  32. data/log_sense.gemspec +1 -1
  33. data/{apache-screenshot.png → screenshots/apache-screenshot.png} +0 -0
  34. data/screenshots/rails-screenshot.png +0 -0
  35. metadata +17 -11
  36. data/lib/log_sense/apache_data_cruncher.rb +0 -147
  37. data/lib/log_sense/rails_data_cruncher.rb +0 -141
  38. data/lib/log_sense/templates/apache.html.erb +0 -115
  39. data/lib/log_sense/templates/rails.txt.erb +0 -22
@@ -1,43 +1,51 @@
1
1
  # coding: utf-8
2
- require 'terminal-table'
3
- require 'json'
4
- require 'erb'
5
- require 'ostruct'
6
- module LogSense
7
- WORDS_SEPARATOR = ' · '
2
+ require "terminal-table"
3
+ require "json"
4
+ require "erb"
5
+ require "ostruct"
8
6
 
7
+ module LogSense
9
8
  #
10
9
  # Emit Data
11
10
  #
12
- module Emitter
13
- def self.human_readable_size(size)
14
- if size < 1024
15
- "%d B" % size
16
- elsif size < 1024 * 1024
17
- "%.2f KB" % (size.to_f / 1024)
18
- elsif size < 1024 * 1024 * 1024
19
- "%.2f MB" % (size.to_f / (1024 * 1024))
20
- else
21
- "%.2f GB" % (size.to_f / (1024 * 1024 * 1024))
22
- end
23
- end
24
-
25
- def self.emit(data = {}, options = {})
26
- @input_format = options[:input_format] || 'apache'
27
- @output_format = options[:output_format] || 'html'
11
+ class Emitter
12
+ CDN_CSS = [
13
+ "https://cdnjs.cloudflare.com/ajax/libs/foundicons/3.0.0/foundation-icons.min.css",
14
+ "https://cdn.jsdelivr.net/npm/foundation-sites@6.7.5/dist/css/foundation.min.css",
15
+ "https://cdn.datatables.net/v/zf/dt-1.11.3/datatables.min.css"
16
+ ].freeze
17
+
18
+ CDN_JS = [
19
+ "https://code.jquery.com/jquery-3.6.2.min.js",
20
+ "https://cdn.datatables.net/v/zf/dt-1.13.1/datatables.min.js",
21
+ "https://cdn.jsdelivr.net/npm/foundation-sites@6.7.5/dist/js/foundation.min.js",
22
+ "https://cdn.jsdelivr.net/npm/vega@5.22.1",
23
+ "https://cdn.jsdelivr.net/npm/vega-lite@5.6.0",
24
+ "https://cdn.jsdelivr.net/npm/vega-embed@6.21.0"
25
+ ].freeze
28
26
 
29
- # for the ERB binding
30
- @reports = method("#{@input_format}_report_specification".to_sym).call(data)
27
+ def self.emit(reports = {}, data = {}, options = {})
28
+ # These are used in templates
29
+ @reports = reports
31
30
  @data = data
32
31
  @options = options
32
+ @report_title = options[:input_format].capitalize
33
+ @format_specific_css = "#{@options[:input_format]}.css.erb"
33
34
 
34
- # determine the main template to read
35
- @template = File.join(File.dirname(__FILE__), 'templates', "#{@input_format}.#{@output_format}.erb")
36
- erb_template = File.read @template
35
+ # Chooses template and destination
36
+ output_format = @options[:output_format]
37
+ output_file = @options[:output_file]
38
+
39
+ # read template and compile
40
+ template = File.join(File.dirname(__FILE__),
41
+ "templates",
42
+ "report_#{output_format}.erb")
43
+ erb_template = File.read template
37
44
  output = ERB.new(erb_template, trim_mode: "-").result(binding)
38
45
 
39
- if options[:output_file]
40
- file = File.open options[:output_file], 'w'
46
+ # output
47
+ if output_file
48
+ file = File.open output_file, "w"
41
49
  file.write output
42
50
  file.close
43
51
  else
@@ -46,41 +54,42 @@ module LogSense
46
54
  end
47
55
 
48
56
  #
49
- # This is used in templates
57
+ # These are used in templates
50
58
  #
59
+
51
60
  def self.render(template, vars = {})
52
- @template = File.join(File.dirname(__FILE__), 'templates', "_#{template}")
53
- erb_template = File.read @template
54
- ERB.new(erb_template, trim_mode: "-")
55
- .result(OpenStruct.new(vars).instance_eval { binding })
61
+ @template = File.join(File.dirname(__FILE__), "templates", "_#{template}")
62
+ if File.exist? @template
63
+ erb_template = File.read @template
64
+ ERB.new(erb_template, trim_mode: "-")
65
+ .result(OpenStruct.new(vars).instance_eval { binding })
66
+ end
56
67
  end
57
68
 
58
69
  def self.escape_javascript(string)
59
70
  js_escape_map = {
60
- '<' => '&lt;',
61
- '</' => '&lt;/',
62
- '\r\n' => '\\r\\n',
63
- '\n' => '\\n',
64
- '\r' => '\\r',
65
- '\\' => ' \\\\',
71
+ #"&" => "&amp;",
72
+ #"%" => "&#37;",
73
+ "<" => "&lt;",
74
+ "\\" => "&bsol;",
66
75
  '"' => ' \\"',
67
76
  "'" => " \\'",
68
- '`' => ' \\`',
69
- '$' => ' \\$'
77
+ "`" => " \\`",
78
+ "$" => " \\$"
70
79
  }
71
- js_escape_map.each do |k, v|
72
- string = string.gsub(k, v)
80
+ js_escape_map.each do |match, replace|
81
+ string = string.gsub(match, replace)
73
82
  end
74
83
  string
75
84
  end
76
85
 
77
86
  def self.slugify(string)
78
- (string.start_with?(/[0-9]/) ? 'slug-' : '') + string.downcase.gsub(' ', '-')
87
+ (string.start_with?(/[0-9]/) ? "slug-" : "") + string.downcase.gsub(" ", "-")
79
88
  end
80
89
 
81
90
  def self.process(value)
82
91
  klass = value.class
83
- [Integer, Float].include?(klass) ? value : escape_javascript(value || '')
92
+ [Integer, Float].include?(klass) ? value : escape_javascript(value || "")
84
93
  end
85
94
 
86
95
  # limit width of special columns, that is, those in keywords
@@ -118,512 +127,5 @@ module LogSense
118
127
  end
119
128
  end
120
129
  end
121
-
122
- #
123
- # Specification of the reports to generate
124
- # Array of hashes with the following information:
125
- # - title: report_title
126
- # header: header of tabular data
127
- # rows: data to show
128
- # column_alignment: specification of column alignments (works for txt reports)
129
- # vega_spec: specifications for Vega output
130
- # datatable_options: specific options for datatable
131
- def self.apache_report_specification(data = {})
132
- [
133
- {
134
- title: 'Daily Distribution',
135
- header: %w[Day DOW Hits Visits Size],
136
- column_alignment: %i[left left right right right],
137
- rows: data[:daily_distribution],
138
- vega_spec: {
139
- 'layer': [
140
- {
141
- 'mark': {
142
- 'type': 'line',
143
- 'point': {
144
- 'filled': false,
145
- 'fill': 'white'
146
- }
147
- },
148
- 'encoding': {
149
- 'y': {'field': 'Hits', 'type': 'quantitative'}
150
- }
151
- },
152
- {
153
- 'mark': {
154
- 'type': 'text',
155
- 'color': '#3E5772',
156
- 'align': 'middle',
157
- 'baseline': 'top',
158
- 'dx': -10,
159
- 'yOffset': -15
160
- },
161
- 'encoding': {
162
- 'text': {'field': 'Hits', 'type': 'quantitative'},
163
- 'y': {'field': 'Hits', 'type': 'quantitative'}
164
- }
165
- },
166
-
167
- {
168
- 'mark': {
169
- 'type': 'line',
170
- 'color': '#A52A2A',
171
- 'point': {
172
- 'color': '#A52A2A',
173
- 'filled': false,
174
- 'fill': 'white',
175
- }
176
- },
177
- 'encoding': {
178
- 'y': {'field': 'Visits', 'type': 'quantitative'}
179
- }
180
- },
181
-
182
- {
183
- 'mark': {
184
- 'type': 'text',
185
- 'color': '#A52A2A',
186
- 'align': 'middle',
187
- 'baseline': 'top',
188
- 'dx': -10,
189
- 'yOffset': -15
190
- },
191
- 'encoding': {
192
- 'text': {'field': 'Visits', 'type': 'quantitative'},
193
- 'y': {'field': 'Visits', 'type': 'quantitative'}
194
- }
195
- },
196
-
197
- ],
198
- 'encoding': {
199
- 'x': {'field': 'Day', 'type': 'temporal'},
200
- }
201
- }
202
- },
203
- {
204
- title: 'Time Distribution',
205
- header: %w[Hour Hits Visits Size],
206
- column_alignment: %i[left right right right],
207
- rows: data[:time_distribution],
208
- vega_spec: {
209
- 'layer': [
210
- {
211
- 'mark': 'bar'
212
- },
213
- {
214
- 'mark': {
215
- 'type': 'text',
216
- 'align': 'middle',
217
- 'baseline': 'top',
218
- 'dx': -10,
219
- 'yOffset': -15
220
- },
221
- 'encoding': {
222
- 'text': {'field': 'Hits', 'type': 'quantitative'},
223
- 'y': {'field': 'Hits', 'type': 'quantitative'}
224
- }
225
- },
226
- ],
227
- 'encoding': {
228
- 'x': {'field': 'Hour', 'type': 'nominal'},
229
- 'y': {'field': 'Hits', 'type': 'quantitative'}
230
- }
231
- }
232
- },
233
- {
234
- title: '20_ and 30_ on HTML pages',
235
- header: %w[Path Hits Visits Size Status],
236
- column_alignment: %i[left right right right right],
237
- rows: data[:most_requested_pages],
238
- datatable_options: 'columnDefs: [{ width: \'40%\', targets: 0 }, { width: \'15%\', targets: [1, 2, 3, 4] }], dataRender: true'
239
- },
240
- {
241
- title: '20_ and 30_ on other resources',
242
- header: %w[Path Hits Visits Size Status],
243
- column_alignment: %i[left right right right right],
244
- rows: data[:most_requested_resources],
245
- datatable_options: 'columnDefs: [{ width: \'40%\', targets: 0 }, { width: \'15%\', targets: [1, 2, 3, 4] }], dataRender: true'
246
- },
247
- {
248
- title: '40_ and 50_x on HTML pages',
249
- header: %w[Path Hits Visits Status],
250
- column_alignment: %i[left right right right],
251
- rows: data[:missed_pages],
252
- datatable_options: 'columnDefs: [{ width: \'40%\', targets: 0 }, { width: \'20%\', targets: [1, 2, 3] }], dataRender: true'
253
- },
254
- {
255
- title: '40_ and 50_ on other resources',
256
- header: %w[Path Hits Visits Status],
257
- column_alignment: %i[left right right right],
258
- rows: data[:missed_resources],
259
- datatable_options: 'columnDefs: [{ width: \'40%\', targets: 0 }, { width: \'20%\', targets: [1, 2, 3] }], dataRender: true'
260
- },
261
- {
262
- title: '40_ and 50_x on HTML pages by IP',
263
- header: %w[IP Hits Paths],
264
- column_alignment: %i[left right left],
265
- # Value is something along the line of:
266
- # [["66.249.79.93", "/adolfo/notes/calendar/2014/11/16.html", "404"],
267
- # ["66.249.79.93", "/adolfo/website-specification/generate-xml-sitemap.org.html", "404"]]
268
- rows: data[:missed_pages_by_ip]&.group_by { |x| x[0] }&.map { |k, v|
269
- [
270
- k,
271
- v.size,
272
- v.map { |x| x[1] }.join(WORDS_SEPARATOR)
273
- ]
274
- }&.sort { |x, y| y[1] <=> x[1] }
275
- },
276
- {
277
- title: '40_ and 50_ on other resources by IP',
278
- header: %w[IP Hits Paths],
279
- column_alignment: %i[left right left],
280
- # Value is something along the line of:
281
- # [["66.249.79.93", "/adolfo/notes/calendar/2014/11/16.html", "404"],
282
- # ["66.249.79.93", "/adolfo/website-specification/generate-xml-sitemap.org.html", "404"]]
283
- rows: data[:missed_resources_by_ip]&.group_by { |x| x[0] }&.map { |k, v|
284
- [
285
- k,
286
- v.size,
287
- v.map { |x| x[1] }.join(WORDS_SEPARATOR)
288
- ]
289
- }&.sort { |x, y| y[1] <=> x[1] }
290
- },
291
- {
292
- title: 'Statuses',
293
- header: %w[Status Count],
294
- column_alignment: %i[left right],
295
- rows: data[:statuses],
296
- vega_spec: {
297
- 'mark': 'bar',
298
- 'encoding': {
299
- 'x': {'field': 'Status', 'type': 'nominal'},
300
- 'y': {'field': 'Count', 'type': 'quantitative'}
301
- }
302
- }
303
- },
304
- {
305
- title: 'Daily Statuses',
306
- header: %w[Date S_2xx S_3xx S_4xx],
307
- column_alignment: %i[left right right right],
308
- rows: data[:statuses_by_day],
309
- vega_spec: {
310
- 'transform': [ {'fold': ['S_2xx', 'S_3xx', 'S_4xx' ] }],
311
- 'mark': 'bar',
312
- 'encoding': {
313
- 'x': {
314
- 'field': 'Date',
315
- 'type': 'ordinal',
316
- 'timeUnit': 'day',
317
- },
318
- 'y': {
319
- 'aggregate': 'sum',
320
- 'field': 'value',
321
- 'type': 'quantitative'
322
- },
323
- 'color': {
324
- 'field': 'key',
325
- 'type': 'nominal',
326
- 'scale': {
327
- 'domain': ['S_2xx', 'S_3xx', 'S_4xx'],
328
- 'range': ['#228b22', '#ff8c00', '#a52a2a']
329
- },
330
- }
331
- }
332
- }
333
- },
334
- {
335
- title: 'Browsers',
336
- header: %w[Browser Hits Visits Size],
337
- column_alignment: %i[left right right right],
338
- rows: data[:browsers],
339
- vega_spec: {
340
- 'layer': [
341
- { 'mark': 'bar' },
342
- {
343
- 'mark': {
344
- 'type': 'text',
345
- 'align': 'middle',
346
- 'baseline': 'top',
347
- 'dx': -10,
348
- 'yOffset': -15
349
- },
350
- 'encoding': {
351
- 'text': {'field': 'Hits', 'type': 'quantitative'},
352
- }
353
- },
354
- ],
355
- 'encoding': {
356
- 'x': {'field': 'Browser', 'type': 'nominal'},
357
- 'y': {'field': 'Hits', 'type': 'quantitative'}
358
- }
359
- }
360
- },
361
- {
362
- title: 'Platforms',
363
- header: %w[Platform Hits Visits Size],
364
- column_alignment: %i[left right right right],
365
- rows: data[:platforms],
366
- vega_spec: {
367
- 'layer': [
368
- { 'mark': 'bar' },
369
- {
370
- 'mark': {
371
- 'type': 'text',
372
- 'align': 'middle',
373
- 'baseline': 'top',
374
- 'dx': -10,
375
- 'yOffset': -15
376
- },
377
- 'encoding': {
378
- 'text': {'field': 'Hits', 'type': 'quantitative'},
379
- }
380
- },
381
- ],
382
- 'encoding': {
383
- 'x': {'field': 'Platform', 'type': 'nominal'},
384
- 'y': {'field': 'Hits', 'type': 'quantitative'}
385
- }
386
- }
387
- },
388
- {
389
- title: 'IPs',
390
- header: %w[IP Hits Visits Size Country],
391
- column_alignment: %i[left right right right left],
392
- rows: data[:ips]
393
- },
394
- {
395
- title: 'Countries',
396
- header: ["Country", "Hits", "Visits", "IPs", "IP List"],
397
- column_alignment: %i[left right right right left],
398
- rows: data[:countries]&.map { |k, v|
399
- [
400
- k,
401
- v.map { |x| x[1] }.inject(&:+),
402
- v.map { |x| x[2] }.inject(&:+),
403
- v.map { |x| x[0] }.size,
404
- v.map { |x| x[0] }.join(WORDS_SEPARATOR)
405
- ]
406
- }&.sort { |x, y| y[3] <=> x[3] }
407
- },
408
- {
409
- title: 'Combined Platform Data',
410
- header: %w[ Browser OS IP Hits Size],
411
- column_alignment: %i[left left left right right],
412
- col: 'small-12 cell',
413
- rows: data[:combined_platforms],
414
- },
415
- {
416
- title: 'Referers',
417
- header: %w[Referers Hits Visits Size],
418
- column_alignment: %i[left right right right],
419
- datatable_options: 'columnDefs: [{ width: \'50%\', targets: 0 } ], dataRender: true',
420
- rows: data[:referers],
421
- col: 'small-12 cell'
422
- },
423
- {
424
- title: 'Streaks',
425
- report: :html,
426
- header: ['IP', 'Date', 'Total HTML', 'Total Other', 'HTML', 'Other'],
427
- column_alignment: %i[left left right right left left],
428
- datatable_options: 'columnDefs: [{ width: \'30%\', targets: [4, 5] }, { width: \'10%\', targets: [0, 1, 2, 3]} ], dataRender: true',
429
- rows: data[:streaks]&.group_by { |x| [x[0], x[1]] }&.map do |k, v|
430
- [
431
- k[0],
432
- k[1],
433
- v.map { |x| x[2] }.compact.select { |x| x.match(/\.html?$/) }.size,
434
- v.map { |x| x[2] }.compact.reject { |x| x.match(/\.html?$/) }.size,
435
- v.map { |x| x[2] }.compact.select { |x| x.match(/\.html?$/) }.join(WORDS_SEPARATOR),
436
- v.map { |x| x[2] }.compact.reject { |x| x.match(/\.html?$/) }.join(WORDS_SEPARATOR)
437
- ]
438
- end,
439
- col: 'small-12 cell'
440
- }
441
- ]
442
- end
443
-
444
- def self.rails_report_specification(data = {})
445
- [
446
- {
447
- title: "Daily Distribution",
448
- header: %w[Day DOW Hits],
449
- column_alignment: %i[left left right],
450
- rows: data[:daily_distribution],
451
- vega_spec: {
452
- "encoding": {
453
- "x": {"field": "Day", "type": "temporal"},
454
- "y": {"field": "Hits", "type": "quantitative"}
455
- },
456
- "layer": [
457
- {
458
- "mark": {
459
- "type": "line",
460
- "point": {
461
- "filled": false,
462
- "fill": "white"
463
- }
464
- }
465
- },
466
- {
467
- "mark": {
468
- "type": "text",
469
- "align": "left",
470
- "baseline": "middle",
471
- "dx": 5
472
- },
473
- "encoding": {
474
- "text": {"field": "Hits", "type": "quantitative"}
475
- }
476
- }
477
- ]
478
- }
479
- },
480
- {
481
- title: "Time Distribution",
482
- header: %w[Hour Hits],
483
- column_alignment: %i[left right],
484
- rows: data[:time_distribution],
485
- vega_spec: {
486
- "layer": [
487
- {
488
- "mark": "bar",
489
- },
490
- {
491
- "mark": {
492
- "type": "text",
493
- "align": "middle",
494
- "baseline": "top",
495
- "dx": -10,
496
- "yOffset": -15
497
- },
498
- "encoding": {
499
- "text": {"field": "Hits", "type": "quantitative"}
500
- }
501
- }
502
- ],
503
- "encoding": {
504
- "x": {"field": "Hour", "type": "nominal"},
505
- "y": {"field": "Hits", "type": "quantitative"}
506
- }
507
- }
508
- },
509
- {
510
- title: "Statuses",
511
- header: %w[Status Count],
512
- column_alignment: %i[left right],
513
- rows: data[:statuses],
514
- vega_spec: {
515
- "layer": [
516
- {
517
- "mark": "bar"
518
- },
519
- {
520
- "mark": {
521
- "type": "text",
522
- "align": "left",
523
- "baseline": "top",
524
- "dx": -10,
525
- "yOffset": -20
526
- },
527
- "encoding": {
528
- "text": {"field": "Count", "type": "quantitative"}
529
- }
530
- }
531
- ],
532
- "encoding": {
533
- "x": {"field": "Status", "type": "nominal"},
534
- "y": {"field": "Count", "type": "quantitative"}
535
- }
536
- }
537
- },
538
- {
539
- title: "Rails Performance",
540
- header: %w[Controller Hits Min Avg Max],
541
- column_alignment: %i[left right right right right],
542
- rows: data[:performance],
543
- vega_spec: {
544
- "layer": [
545
- {
546
- "mark": {
547
- "type": "point",
548
- "name": "data_points"
549
- }
550
- },
551
- {
552
- "mark": {
553
- "name": "label",
554
- "type": "text",
555
- "align": "left",
556
- "baseline": "middle",
557
- "dx": 5,
558
- "yOffset": 0
559
- },
560
- "encoding": {
561
- "text": {"field": "Controller"},
562
- "fontSize": {"value": 8}
563
- },
564
- },
565
- ],
566
- "encoding": {
567
- "x": {"field": "Avg", "type": "quantitative"},
568
- "y": {"field": "Hits", "type": "quantitative"}
569
- },
570
- }
571
- },
572
- {
573
- title: "Fatal Events",
574
- header: %w[Date IP URL Description Log ID],
575
- column_alignment: %i[left left left left left],
576
- rows: data[:fatal],
577
- col: 'small-12 cell'
578
- },
579
- {
580
- title: 'Internal Server Errors',
581
- header: %w[Date Status IP URL Description Log ID],
582
- column_alignment: %i[left left left left left left],
583
- rows: data[:internal_server_error],
584
- col: 'small-12 cell'
585
- },
586
- {
587
- title: 'Errors',
588
- header: %w[Log ID Context Description Count],
589
- column_alignment: %i[left left left left],
590
- rows: data[:error],
591
- col: 'small-12 cell'
592
- },
593
- {
594
- title: 'IPs',
595
- header: %w[IPs Hits Country],
596
- column_alignment: %i[left right left],
597
- rows: data[:ips]
598
- },
599
- {
600
- title: 'Countries',
601
- header: %w[Country Hits IPs],
602
- column_alignment: %i[left right left],
603
- rows: data[:countries]&.map { |k, v|
604
- [
605
- k,
606
- v.map { |x| x[1] }.inject(&:+),
607
- v.map { |x| x[0] }.join(WORDS_SEPARATOR)
608
- ]
609
- }&.sort { |x, y| x[0] <=> y[0] }
610
- },
611
- {
612
- title: 'Streaks',
613
- report: :html,
614
- header: %w[IP Date Total Resources],
615
- column_alignment: %i[left left right right left left],
616
- rows: data[:streaks]&.group_by { |x| [x[0], x[1]] }&.map do |k, v|
617
- [
618
- k[0],
619
- k[1],
620
- v.size,
621
- v.map { |x| x[2] }.join(WORDS_SEPARATOR)
622
- ]
623
- end,
624
- col: 'small-12 cell'
625
- }
626
- ]
627
- end
628
130
  end
629
131
  end