greenhat 0.4.0 → 0.6.1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/bin/greenhat +1 -2
  3. data/lib/greenhat/accessors/disk.rb +1 -1
  4. data/lib/greenhat/accessors/gitlab.rb +4 -2
  5. data/lib/greenhat/archive.rb +7 -1
  6. data/lib/greenhat/cli.rb +24 -11
  7. data/lib/greenhat/entrypoint.rb +37 -34
  8. data/lib/greenhat/host.rb +1 -1
  9. data/lib/greenhat/logbot.rb +1 -1
  10. data/lib/greenhat/paper/flag_helper.rb +18 -0
  11. data/lib/greenhat/paper/paper_helper.rb +118 -0
  12. data/lib/greenhat/paper.rb +34 -0
  13. data/lib/greenhat/reports/builder.rb +98 -0
  14. data/lib/greenhat/reports/helpers.rb +101 -0
  15. data/lib/greenhat/reports/internal_methods.rb +156 -0
  16. data/lib/greenhat/reports/reports/errors.rb +49 -0
  17. data/lib/greenhat/reports/reports/faststats.rb +42 -0
  18. data/lib/greenhat/reports/reports/full.rb +143 -0
  19. data/lib/greenhat/reports/runner.rb +58 -0
  20. data/lib/greenhat/reports/shared.rb +37 -0
  21. data/lib/greenhat/reports/shell_helper.rb +34 -0
  22. data/lib/greenhat/reports.rb +79 -0
  23. data/lib/greenhat/settings.rb +35 -8
  24. data/lib/greenhat/shell/args.rb +9 -9
  25. data/lib/greenhat/shell/color_string.rb +1 -1
  26. data/lib/greenhat/shell/faststats.rb +24 -5
  27. data/lib/greenhat/shell/field_helper.rb +1 -1
  28. data/lib/greenhat/shell/filter_help.rb +65 -185
  29. data/lib/greenhat/shell/gitlab.rb +1 -0
  30. data/lib/greenhat/shell/log.rb +24 -30
  31. data/lib/greenhat/shell/markdown.rb +355 -352
  32. data/lib/greenhat/shell/process.rb +11 -5
  33. data/lib/greenhat/shell/query.rb +534 -0
  34. data/lib/greenhat/shell/report.rb +415 -410
  35. data/lib/greenhat/shell/reports.rb +41 -0
  36. data/lib/greenhat/shell/shell_helper.rb +95 -387
  37. data/lib/greenhat/shell.rb +22 -3
  38. data/lib/greenhat/thing/file_types.rb +30 -1
  39. data/lib/greenhat/thing/formatters/api_json.rb +4 -2
  40. data/lib/greenhat/thing/formatters/bracket_log.rb +1 -1
  41. data/lib/greenhat/thing/formatters/clean_raw.rb +1 -1
  42. data/lib/greenhat/thing/formatters/colon_split_strip.rb +2 -2
  43. data/lib/greenhat/thing/formatters/dotenv.rb +1 -1
  44. data/lib/greenhat/thing/formatters/format.rb +0 -11
  45. data/lib/greenhat/thing/formatters/free_m.rb +2 -2
  46. data/lib/greenhat/thing/formatters/json.rb +41 -17
  47. data/lib/greenhat/thing/formatters/json_shellwords.rb +3 -2
  48. data/lib/greenhat/thing/formatters/kube_json.rb +3 -2
  49. data/lib/greenhat/thing/formatters/multiline_json.rb +1 -1
  50. data/lib/greenhat/thing/formatters/nginx.rb +5 -1
  51. data/lib/greenhat/thing/formatters/runner_log.rb +70 -0
  52. data/lib/greenhat/thing/formatters/table.rb +17 -3
  53. data/lib/greenhat/thing/formatters/time_json.rb +12 -1
  54. data/lib/greenhat/thing/helpers.rb +0 -11
  55. data/lib/greenhat/thing/info_format.rb +4 -4
  56. data/lib/greenhat/thing/kind.rb +1 -1
  57. data/lib/greenhat/thing.rb +21 -25
  58. data/lib/greenhat/version.rb +1 -1
  59. data/lib/greenhat.rb +6 -8
  60. metadata +32 -4
  61. data/lib/greenhat/pry_helpers.rb +0 -51
  62. data/lib/greenhat/thing/super_log.rb +0 -102
@@ -30,8 +30,10 @@ module GreenHat
30
30
  puts
31
31
  end
32
32
 
33
- def self.filter_help
34
- ShellHelper::Filter.help
33
+ def self.filter_help(raw = {})
34
+ args, flags, _args = Args.parse(raw)
35
+
36
+ ShellHelper.show(ShellHelper::Filter.help(args.first), flags)
35
37
  end
36
38
 
37
39
  def self.ps(raw = {})
@@ -45,6 +47,10 @@ module GreenHat
45
47
  ShellHelper.file_output(files, flags)
46
48
  end
47
49
 
50
+ def self.default(raw_list)
51
+ filter(raw_list)
52
+ end
53
+
48
54
  def self.filter(raw = {})
49
55
  # Argument Parsing
50
56
  files, flags, args = Args.parse(raw)
@@ -52,14 +58,14 @@ module GreenHat
52
58
  # Prepare Log List
53
59
  file_list = ShellHelper.prepare_list(files, GreenHat::Ps.things)
54
60
 
55
- results = ShellHelper.filter_start(file_list, flags, args)
61
+ results = Query.start(file_list, flags, args)
56
62
 
57
63
  # Check Search Results
58
64
  if results.instance_of?(Hash) && results.values.flatten.empty?
59
65
  puts 'No results'.pastel(:red)
60
66
  else
61
- # This causes the key 'colorized' output to also be included
62
- ShellHelper.show(results.to_a.compact.flatten, flags)
67
+
68
+ ShellHelper.show(results, flags)
63
69
  end
64
70
  end
65
71
  end
@@ -0,0 +1,534 @@
1
+ # NameSpace
2
+ module GreenHat
3
+ # Query Handlers
4
+ # rubocop:disable Metrics/ModuleLength
5
+ module Query
6
+ # Main Entry Point for Filtering
7
+ def self.start(files, flags = {}, args = {})
8
+ # Convert to Things / Thing.list == processed files
9
+ files = ShellHelper.find_things(files, flags, Thing.list).select(&:query?)
10
+
11
+ # Breakdown by Interval
12
+ return process_interval(files, flags, args) if flags.key? :interval
13
+
14
+ # Ignore Archive/Host Dividers
15
+ if flags[:combine]
16
+ # Add SRC Field to Combined Entries
17
+ results = combine_src_add(files)
18
+ Query.filter(results.flatten.compact, flags, args)
19
+ else
20
+ file_filter(files, flags, args)
21
+ end
22
+ end
23
+
24
+ def self.combine_src_add(files)
25
+ files.reject(&:blank?).map do |file|
26
+ src = file.archive&.friendly_name || file&.name
27
+ file.data.map do |entry|
28
+ bit = entry.clone
29
+ bit[:src] = src
30
+ bit
31
+ end
32
+ end
33
+ end
34
+
35
+ # Simplify Loop for Query.start
36
+ def self.file_filter(files, flags = {}, args = {})
37
+ # Iterate and Preserve Archive/Host Index
38
+ files.each_with_object([]) do |file, obj|
39
+ # Ignore Empty Results / No Thing
40
+ next if file&.blank?
41
+
42
+ # Include Total Count in Name
43
+ results = Query.filter(file.data, flags, args)
44
+
45
+ next if results.count.zero? # Skip if there are no results
46
+
47
+ duration = calculate_duration(results)
48
+
49
+ # Create Title
50
+ title = create_title(file, flags, results)
51
+
52
+ # Append Duration
53
+ title += " #{duration.pastel(:cyan, :dim)}" unless duration.blank?
54
+
55
+ # Add Title
56
+ obj.push(title)
57
+
58
+ # Add Results
59
+ obj.concat(results)
60
+
61
+ obj
62
+ end
63
+ end
64
+
65
+ # Helper to simplify Title Creation
66
+ def self.create_title(file, flags, results)
67
+ title = file.friendly_name
68
+ # Ignore for Total Results
69
+ title += " #{results.count}".pastel(:bright_black) unless flags.key?(:total)
70
+ title
71
+ end
72
+
73
+ # Helper to transform fields from one to another (timestamp => time)
74
+ def self.process_transform(transform, results)
75
+ # TODO: Eventually provide more splat options
76
+ from, to, *splat = transform
77
+ results.each do |entry|
78
+ entry[to.to_sym] = case splat
79
+ when [:to_i]
80
+ entry[from.to_sym].to_i
81
+ else
82
+ entry[from.to_sym]
83
+ end
84
+ end
85
+ end
86
+
87
+ # Organize Results by Interval
88
+ # TODO Simplify
89
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
90
+ def self.process_interval(files, flags, args)
91
+ data = files.reject(&:blank?).map(&:data).flatten.compact
92
+
93
+ # Handle Transform
94
+ process_transform(flags[:transform], data) if flags.key? :transform
95
+
96
+ interval = ChronicDuration.parse(flags[:interval]) || 5.minutes
97
+ index = build_time_index(data, interval)
98
+ index.transform_values! { |_y| [] }
99
+
100
+ data.each do |r|
101
+ index[r.time.floor(interval)] << r
102
+ end
103
+
104
+ output = index.each_with_object({}) do |(time, entries), obj|
105
+ results = Query.filter(entries, flags, args)
106
+
107
+ next if results.count.zero? # Skip if No Results
108
+
109
+ duration = calculate_duration(results)
110
+
111
+ # Timzone Manipulation
112
+ time_title = flags.key?(:time_zone) ? time.in_time_zone(flags[:time_zone]) : time
113
+
114
+ title = time_title.to_s.pastel(:bright_blue, :bold)
115
+ title += " #{results.count}".pastel(:bright_black) unless flags.key?(:total)
116
+
117
+ # Append Duration
118
+ title += " #{duration.pastel(:cyan, :dim)}" unless duration.blank?
119
+
120
+ # Add Results
121
+ obj[title] = results
122
+
123
+ obj
124
+ end
125
+
126
+ # Remove Empty Intervals / There may be empty here due to results
127
+ output.reject! { |_k, v| v.flatten.empty? }
128
+
129
+ output.to_a.flatten(2)
130
+ end
131
+
132
+ def self.calculate_duration(results)
133
+ # Skip for Pluck
134
+ only_with_time = results.select { |x| x.instance_of?(Hash) && x.key?(:time) && x[:time].instance_of?(Time) }
135
+
136
+ # If slice is used ignore
137
+ return nil if only_with_time.empty?
138
+
139
+ sorted = only_with_time.map(&:time).sort
140
+ humanize_time(sorted.first, sorted.last)
141
+ end
142
+
143
+ # Replace TimeDifference with https://stackoverflow.com/a/4136485/1678507
144
+ def self.humanize_time(time_start, time_end, increments = 2)
145
+ miliseconds = (time_end - time_start) * 1000
146
+
147
+ list = [[1000, :ms], [60, :s], [60, :m], [24, :h]].map do |count, name|
148
+ next unless miliseconds.positive?
149
+
150
+ miliseconds, n = miliseconds.divmod(count)
151
+
152
+ "#{n.to_i}#{name}" unless n.to_i.zero?
153
+ end
154
+
155
+ list.compact.reverse[0..increments - 1].join(' ')
156
+ end
157
+
158
+ # Filter Logic
159
+ # TODO: Simplify
160
+ def self.filter(data, flags = {}, args = {})
161
+ results = data.clone
162
+
163
+ # Handle Transform
164
+ process_transform(flags[:transform], results) if flags.key? :transform
165
+
166
+ results.select! do |row|
167
+ args.send(flags.logic || :all?) do |arg|
168
+ filter_row_key(row, arg, flags)
169
+ end
170
+ end
171
+
172
+ # Ensure presecense of a specific field
173
+ results = filter_exists(results, flags[:exists]) if flags.key?(:exists)
174
+
175
+ # Time Zone
176
+ results = filter_modify_timezone(results, flags[:time_zone]) if flags.key?(:time_zone)
177
+
178
+ # Time Filtering
179
+ results = filter_time(results, flags) if flags.key?(:start) || flags.key?(:end)
180
+
181
+ # Strip Results if Slice is defined
182
+ results = filter_slice(results, flags[:slice]) if flags.key?(:slice)
183
+
184
+ # Strip Results if Except is defined
185
+ results = filter_except(results, flags[:except]) if flags.key?(:except)
186
+
187
+ # Remove Blank from either slice or except
188
+ results.reject!(&:empty?)
189
+
190
+ # Sort / Reverse by default
191
+ if flags.key?(:sort)
192
+ results.sort_by! { |x| x.slice(*flags[:sort]).values }
193
+ results.reverse!
194
+ end
195
+
196
+ # JSON Formatting
197
+ results = results.map { |x| Oj.dump(x) } if flags.key?(:json)
198
+
199
+ # Show Unique Only
200
+ results = filter_uniq(results, flags[:uniq]) if flags.key?(:uniq)
201
+
202
+ # Reverse
203
+ results.reverse! if flags[:reverse]
204
+
205
+ # Count occurrences / Skip Results
206
+ return filter_stats(results, flags) if flags.key?(:stats)
207
+
208
+ # Percentile Breakdown
209
+ return filter_percentile(results, flags) if flags.key?(:percentile)
210
+
211
+ # Limit before Pluck / Flattening
212
+ results = filter_limit(results, flags[:limit]) if flags.key?(:limit)
213
+
214
+ # Pluck
215
+ results = filter_pluck(results, flags[:pluck]) if flags.key?(:pluck)
216
+
217
+ # Total Counter
218
+ return [ShellHelper.total_count(results, flags)] if flags.key?(:total)
219
+
220
+ results
221
+ end
222
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
223
+
224
+ # Create list of Times
225
+ def self.build_time_index(results, interval = 5.minutes)
226
+ index = {}
227
+ start = results.min_by(&:time).time.floor(interval)
228
+ finish = results.max_by(&:time).time.floor(interval)
229
+
230
+ loop do
231
+ index[start] = 0
232
+ start += interval
233
+ break if start > finish
234
+ end
235
+
236
+ index
237
+ rescue StandardError => e
238
+ LogBot.fatal('Index Error', e.message)
239
+ ensure
240
+ {}
241
+ end
242
+
243
+ # Limit / Ensure Exists and Valid Number
244
+ def self.filter_limit(results, limit)
245
+ return results unless limit.integer? && limit.positive?
246
+
247
+ results.shift limit
248
+ end
249
+
250
+ def self.filter_modify_timezone(results, time_zone)
251
+ results.map do |x|
252
+ next unless x.key? :time
253
+
254
+ x = x.clone # Prevent Top Level Modification
255
+ x[:time] = x[:time].in_time_zone time_zone
256
+
257
+ x
258
+ end
259
+ end
260
+
261
+ # Filter Start and End Times
262
+ # TODO: This is a bit icky, simplify/dry
263
+ def self.filter_time(results, flags)
264
+ if flags.key?(:start)
265
+ begin
266
+ time_start = Time.parse(flags[:start])
267
+
268
+ results.select! do |x|
269
+ if x.time
270
+ time_start < x.time
271
+ else
272
+ true
273
+ end
274
+ end
275
+ rescue StandardError
276
+ puts 'Unable to Process Start Time Filter'.pastel(:red)
277
+ end
278
+ end
279
+
280
+ if flags.key?(:end)
281
+ begin
282
+ time_start = Time.parse(flags[:end])
283
+
284
+ results.select! do |x|
285
+ if x.time
286
+ time_start > x.time
287
+ else
288
+ true
289
+ end
290
+ end
291
+ rescue StandardError
292
+ puts 'Unable to Process End Time Filter'.pastel(:red)
293
+ end
294
+ end
295
+
296
+ results
297
+ end
298
+
299
+ def self.filter_except(results, except)
300
+ # Avoid Empty Results
301
+ if except.empty?
302
+ filter_empty_arg('except')
303
+ return results
304
+ end
305
+
306
+ results.map { |row| row.except(*except) }
307
+ end
308
+
309
+ def self.filter_exists(results, exists)
310
+ # Avoid Empty Results
311
+ if exists.empty?
312
+ filter_empty_arg('exists')
313
+ return results
314
+ end
315
+
316
+ results.select { |row| (exists - row.keys).empty? }
317
+ end
318
+
319
+ def self.filter_slice(results, slice)
320
+ # Avoid Empty Results
321
+ if slice.empty?
322
+ filter_empty_arg('slice')
323
+ return results
324
+ end
325
+
326
+ results.compact.map { |row| row.slice(*slice) }
327
+ end
328
+
329
+ def self.filter_pluck(results, pluck)
330
+ # Avoid Empty Results
331
+ if pluck.empty?
332
+ filter_empty_arg('pluck')
333
+ return results
334
+ end
335
+
336
+ results.map { |x| x.slice(*pluck).values }.flatten
337
+ end
338
+
339
+ def self.filter_uniq(results, unique)
340
+ # Avoid Empty Results
341
+ if unique.empty?
342
+ filter_empty_arg('uniq')
343
+ return results
344
+ end
345
+
346
+ unique.map do |field|
347
+ results.uniq { |x| x[field] }
348
+ end.inject(:&)
349
+ end
350
+
351
+ # TODO: Make better
352
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
353
+ def self.filter_percentile(results, flags)
354
+ output = {}
355
+
356
+ results.each do |entry|
357
+ entry.each do |key, value|
358
+ # Numbers only
359
+ next unless value.instance_of?(Integer) || value.instance_of?(Float)
360
+
361
+ output[key] ||= []
362
+ output[key].push value
363
+ end
364
+ end
365
+
366
+ data = output.map do |key, values|
367
+ # Support Rounding
368
+ l99 = values.percentile(0.99)
369
+ l99 = l99.round(flags.round) if flags.round
370
+
371
+ l95 = values.percentile(0.99)
372
+ l95 = l95.round(flags.round) if flags.round
373
+
374
+ {
375
+ key: key,
376
+ '99' => l99,
377
+ '95' => l95,
378
+ mean: flags.round ? values.mean.round(flags.round) : values.mean,
379
+ min: flags.round ? values.min.round(flags.round) : values.min,
380
+ max: flags.round ? values.max.round(flags.round) : values.max,
381
+ count: values.count
382
+ }
383
+ end
384
+
385
+ headers = data.flat_map(&:keys).uniq
386
+ [[headers, data.map(&:values)]] # Multiple Arrays for Results Flatten
387
+ end
388
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
389
+
390
+ def self.filter_stats(results, flags)
391
+ stats = flags[:stats]
392
+
393
+ # Avoid Empty Results
394
+ if stats.empty?
395
+ filter_empty_arg('stats')
396
+ return results
397
+ end
398
+
399
+ # Loop through Stats, Separate Hash/Tables
400
+ stats.map do |field|
401
+ occurrences = filter_count_occurrences(results, field, flags)
402
+
403
+ # Use Truncate For Long Keys
404
+ occurrences.transform_keys! { |key| key.to_s[0..flags[:truncate]] } if flags[:truncate]
405
+
406
+ # Total Occurences
407
+ total = occurrences.values.sum
408
+
409
+ # Percs
410
+ occurrences.transform_values! do |count|
411
+ [
412
+ count,
413
+ " #{percent(count, total)}%".pastel(:bright_black)
414
+ ]
415
+ end
416
+
417
+ # Sort by total occurances / New Variable for Total
418
+ output = occurrences.sort_by(&:last).to_h.transform_values!(&:join).to_a
419
+
420
+ # Append Header / Total with field name
421
+ output.unshift([field.to_s.pastel(:bright_black), total])
422
+
423
+ # Format
424
+ output.to_h
425
+ end
426
+ end
427
+
428
+ # Percent Helper
429
+ def self.percent(value, total)
430
+ ((value / total.to_f) * 100).round
431
+ end
432
+
433
+ # Helper to Count occurrences
434
+ def self.filter_count_occurrences(results, field, flags = {})
435
+ results.each_with_object(Hash.new(0)) do |entry, counts|
436
+ if entry.key? field
437
+ # Rounding in pagination breaks stats
438
+ key = if flags.key?(:round) && entry[field].numeric?
439
+ entry[field].to_f.round(flags.round)
440
+ else
441
+ entry[field]
442
+ end
443
+
444
+ counts[key] += 1
445
+ else
446
+ counts['None'.pastel(:bright_black)] += 1
447
+ end
448
+
449
+ counts
450
+ end
451
+ end
452
+
453
+ def self.filter_empty_arg(arg)
454
+ puts [
455
+ 'Ignoring'.pastel(:bright_yellow),
456
+ "--#{arg}".pastel(:cyan),
457
+ 'it requires an argument'.pastel(:red)
458
+ ].join(' ')
459
+ end
460
+
461
+ # Break out filter row logic into separate method
462
+ def self.filter_row_key(row, arg, flags)
463
+ return false if row.nil? # Nothing to filter if row empty
464
+
465
+ # Ignore Other Logic if Field isn't even included / Full Text Searching
466
+ return false unless row.key?(arg[:field]) || arg[:field] == :text
467
+
468
+ # Sensitivity Check / Check for Match / Full Text Searching
469
+ search_data = arg[:field] == :text ? row : row[arg.field]
470
+ match = filter_row_entry(search_data.to_s, arg, flags)
471
+
472
+ # Pivot of off include vs exclude
473
+ if arg.bang
474
+ !match
475
+ else
476
+ match
477
+ end
478
+ end
479
+
480
+ # Field Partial / Case / Exact search
481
+ def self.filter_row_entry(entry, arg, flags)
482
+ # Exact Matching / Unless doing full text search
483
+ return entry == arg.value.to_s if flags.key?(:exact) && arg.field != :text
484
+
485
+ # Cast to String/Integer Helper
486
+ entry, value = filter_entry_cast(entry, arg, flags)
487
+
488
+ entry.send(arg.logic, value)
489
+ end
490
+
491
+ # Handle casting to strings or integers
492
+ def self.filter_entry_cast(entry, arg, flags)
493
+ # Cast to String
494
+ value = arg.value.to_s
495
+
496
+ # No Logic on Empty Entries
497
+ return [entry, value] if entry.empty?
498
+
499
+ case arg.logic
500
+ when :include?
501
+
502
+ # Exact Case argument
503
+ unless flags.key?(:case)
504
+ entry = entry.downcase
505
+ value = value.downcase
506
+ end
507
+ when :>=, :<=
508
+ entry = entry.to_i if entry.numeric?
509
+ value = value.to_i if value&.numeric?
510
+ end
511
+
512
+ [entry, value]
513
+ end
514
+
515
+ def self.filter_entry_logic(entry, arg)
516
+ entry.send(arg.logic, arg.value)
517
+ end
518
+ end
519
+
520
+ # Internal Query Helper
521
+ # query = 'gitlab-rails/application_json.log --message!="Cannot obtain an exclusive lease" --severity=error'
522
+ # Query.filter_internal(query)
523
+ def self.filter_internal(search = '')
524
+ files, flags, args = Args.parse(Shellwords.split(search))
525
+ flags[:combine] = true
526
+
527
+ # Default to everything
528
+ files = Thing.all.map(&:name) if files.empty?
529
+
530
+ Query.start(files, flags, args)
531
+ end
532
+
533
+ # rubocop:enable Metrics/ModuleLength
534
+ end