log_sense 1.5.2 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
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