greenhat 0.1.5 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6b9e87957f5dfc919379519552041ce83b8daa5923b0e89fc64424e050d8981
4
- data.tar.gz: 412b2c4db6613c707c8aad3e39e41a3fb018d1834f5ef17000b11b3af582c639
3
+ metadata.gz: 79572c7389cb4cd519007f185a7fc249a771c08fa1fcbde8645d99786db42736
4
+ data.tar.gz: 6904a95f759a1dc0e866733f0a6220c62b47f3985998f82e2efd4619fdabe390
5
5
  SHA512:
6
- metadata.gz: da4b45b6433d2c4c786bca4b46dc0cfad9b879796e6040b9efceb5ba5181f329c7d3792aef458fde54698791f03830168aad17b9b7d31191aa2e717407444662
7
- data.tar.gz: 3d53ed5c45398acc3a317cc41a5573b82ab1c263e947c94cc419f235efd5bc527905f4484867968535f12aed15c3fbb79abe1d63093402f845c572557b7e288d
6
+ metadata.gz: d44e38727ce532ddbdcf8cc9aad1f5d7ee26a113ee589489bb5f15a99e616d06f66d304dc1e7735a679a707ed99323a45184234779066ca9f1f2cdea3e1b87e9
7
+ data.tar.gz: 6fb959d970482e244bea30f39ccd7357ce442fbc886df0084f425c3540b662a86972889213273ed5e734cace0f6b2b710c356c5585f98f76ffc4763ce2c68b10
data/lib/greenhat.rb CHANGED
@@ -76,5 +76,6 @@ require 'greenhat/pry_helpers'
76
76
  require 'greenhat/tty/line'
77
77
  require 'greenhat/tty/reader'
78
78
  require 'greenhat/tty/custom_line'
79
+ require 'greenhat/tty/columns'
79
80
 
80
81
  Warning.ignore(/The table size exceeds the currently set width/)
@@ -20,8 +20,64 @@ module GreenHat
20
20
  end
21
21
  end
22
22
 
23
- def self.df
24
- Thing.where(name: 'df_h')
23
+ def self.df(args = {})
24
+ things = Thing.where(name: 'df_h')
25
+
26
+ # Host / Archive
27
+ things.select! { |x| x.archive? args.archive } if args.archive
28
+
29
+ things
30
+ end
31
+
32
+ # Unified Output Handler
33
+ # rubocop:disable Metrics/MethodLength
34
+ def self.format_output(file, name = false, limit = nil, filter = %w[tmpfs loop])
35
+ output = []
36
+
37
+ output << file.friendly_name if name
38
+
39
+ # Reject TMPFS
40
+ disks = file.data.sort_by { |x| x.use.to_i }.reverse
41
+
42
+ # Filter
43
+ disks.reject! { |x| filter.any? { |y| x.filesystem.include? y } }
44
+
45
+ disks = disks[0..limit - 1] if limit
46
+
47
+ pad_mount, pad_size, pad_used, pad_avail = GreenHat::Disk.padding(disks)
48
+
49
+ # Headers
50
+ output << [
51
+ 'Mount'.ljust(pad_mount).colorize(:blue),
52
+ 'Size'.ljust(pad_size).colorize(:magenta),
53
+ 'Used'.ljust(pad_used).colorize(:cyan),
54
+ 'Avail'.ljust(pad_avail).colorize(:white),
55
+ '% Use'.ljust(pad_avail).colorize(:green)
56
+ ].join
57
+
58
+ # Table Summary
59
+ disks.map do |disk|
60
+ # Pretty Disk Use
61
+ use = [
62
+ disk.use.rjust(5).ljust(5).colorize(:green),
63
+ ' ['.colorize(:blue),
64
+ ('=' * (disk.use.to_i / 2)).colorize(:green),
65
+ ' ' * (50 - disk.use.to_i / 2),
66
+ ']'.colorize(:blue)
67
+ ].join
68
+
69
+ # Whole Thing
70
+ output << [
71
+ disk.mounted_on.ljust(pad_mount).colorize(:blue),
72
+ disk[:size].to_s.ljust(pad_size).colorize(:magenta),
73
+ disk.used.to_s.ljust(pad_used).colorize(:cyan),
74
+ disk.avail.to_s.ljust(pad_avail).colorize(:white),
75
+ use
76
+ ].join
77
+ end
78
+
79
+ output
25
80
  end
81
+ # rubocop:enable Metrics/MethodLength
26
82
  end
27
83
  end
@@ -1,8 +1,13 @@
1
1
  module GreenHat
2
2
  # Sidekiq Log Helpers
3
3
  module Ps
4
- def self.ps
5
- Thing.where(name: 'ps')
4
+ def self.ps(args)
5
+ things = Thing.where(name: 'ps')
6
+
7
+ # Host / Archive
8
+ things.select! { |x| x.archive? args.archive } if args.archive
9
+
10
+ things
6
11
  end
7
12
  end
8
13
  end
data/lib/greenhat/cli.rb CHANGED
@@ -379,11 +379,7 @@ module GreenHat
379
379
  next
380
380
  end
381
381
 
382
- if line =~ /^exit/i
383
- Settings.cmd_write
384
-
385
- break
386
- end
382
+ break if line =~ /^exit/i
387
383
 
388
384
  Settings.cmd_add(line) unless line.blank?
389
385
 
@@ -4,13 +4,41 @@ module GreenHat
4
4
  module Settings
5
5
  def self.settings
6
6
  @settings ||= {
7
- history: []
7
+ history: [],
8
+
9
+ # round: [2],
10
+ # page: [:true] Automatic,
11
+ truncate: [TTY::Screen.width * 4]
12
+
8
13
  }
9
14
  end
10
15
 
16
+ # Load User Settings and drop them into settings
17
+ def self.settings_load
18
+ return true unless File.exist?(settings_file)
19
+
20
+ Oj.load(File.read(settings_file)).each do |key, value|
21
+ settings[key] = value
22
+ end
23
+ end
24
+
25
+ def self.settings_file
26
+ "#{dir}/settings.json"
27
+ end
28
+
29
+ # Set any Log Arguments that weren't set otherwise
30
+ def self.default_log_args(args, skip_args)
31
+ args[:round] ||= settings.round unless skip_args.include?(:round)
32
+ args[:page] ||= settings.page unless skip_args.include?(:page)
33
+ args[:truncate] ||= settings.truncate unless skip_args.include?(:truncate)
34
+ end
35
+
11
36
  def self.start
12
37
  Dir.mkdir dir unless Dir.exist? dir
13
38
 
39
+ # Load User Settings
40
+ settings_load
41
+
14
42
  # CMD History Loading / Tracking
15
43
  File.write(cmd_file, "\n") unless File.exist? cmd_file
16
44
  end
@@ -33,8 +61,8 @@ module GreenHat
33
61
  File.read(cmd_file).split("\n")
34
62
  end
35
63
 
36
- def self.cmd_write
37
- File.write(cmd_file, cmd_history_clean.join("\n"))
64
+ def self.cmd_history_clear
65
+ File.write(cmd_file, "\n")
38
66
  end
39
67
 
40
68
  def self.cmd_add(line)
@@ -9,8 +9,8 @@ module GreenHat
9
9
  Disk.df
10
10
  end
11
11
 
12
- def self.ps
13
- Process.ps
12
+ def self.ps(raw = {})
13
+ Process.ps raw
14
14
  end
15
15
 
16
16
  def self.netstat
@@ -63,11 +63,11 @@ module GreenHat
63
63
  end
64
64
 
65
65
  def self.history_clear
66
- File.write(GreenHat::Cli.history_file, "\n")
66
+ Settings.cmd_history_clear
67
67
  end
68
68
 
69
69
  def self.history
70
- File.read(GreenHat::Cli.history_file).split("\n").each_with_index do |line, i|
70
+ Settings.cmd_history_clean.each_with_index do |line, i|
71
71
  puts "#{i.to_s.ljust(3).colorize(:magenta)} #{line}"
72
72
  end
73
73
  end
@@ -0,0 +1,43 @@
1
+ module GreenHat
2
+ module ShellHelper
3
+ # Helper to colorize and make outtput easier to read
4
+ module StringColor
5
+ def self.do(key, entry)
6
+ LogBot.debug('Unknown Format', entry.class) if ENV['DEBUG'] && !entry.instance_of?(String)
7
+
8
+ # Other Helpful colorizers
9
+ if colorize?(key)
10
+ colorize(key, entry)
11
+ else
12
+ entry.to_s
13
+ end
14
+ end
15
+
16
+ # Add Color?
17
+ def self.colorize?(key)
18
+ [:severity].any? key
19
+ end
20
+
21
+ # General Key/Value Coloring
22
+ def self.colorize(key, value)
23
+ case key
24
+ when :severity then severity(value)
25
+ else
26
+ value.to_s
27
+ end
28
+ end
29
+ # ----
30
+
31
+ def self.severity(value)
32
+ case value.to_s.downcase.to_sym
33
+ when :debug then value.colorize(:blue)
34
+ when :info then value.colorize(:cyan)
35
+ when :warn then value.colorize(:yellow)
36
+ when :fatal, :error then value.colorize(:light_red)
37
+ else
38
+ value.to_s
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -13,55 +13,16 @@ module GreenHat
13
13
  ShellHelper.file_output GreenHat::Disk.df
14
14
  end
15
15
 
16
- # rubocop:disable Metrics/MethodLength,Metrics/BlockLength
17
- def self.free
18
- GreenHat::Disk.df.each do |file|
19
- # Reject TMPFS
20
- puts file.friendly_name
21
- disks = file.data.sort_by { |x| x.use.to_i }.reverse.reject { |x| x.filesystem.include? 'tmpfs' }
16
+ def self.free(raw = {})
17
+ _log_list, _1opts, args = ShellHelper.param_parse(raw)
22
18
 
23
- # pad_mount = GreenHat::Disk.max_padding(disks, :mounted_on)
24
- # pad_size = GreenHat::Disk.max_padding(disks, :size)
25
- # pad_used = GreenHat::Disk.max_padding(disks, :used)
26
- # pad_avail = GreenHat::Disk.max_padding(disks, :avail)
27
-
28
- pad_mount, pad_size, pad_used, pad_avail = GreenHat::Disk.padding(disks)
29
-
30
- # Headers
31
- puts [
32
- 'Mount'.ljust(pad_mount).colorize(:blue),
33
- 'Size'.ljust(pad_size).colorize(:magenta),
34
- 'Used'.ljust(pad_used).colorize(:cyan),
35
- 'Avail'.ljust(pad_avail).colorize(:white),
36
- '% Use'.ljust(pad_avail).colorize(:green)
37
- ].join
38
-
39
- # Table Summary
40
- disks.map do |disk|
41
- # Pretty Disk Use
42
- use = [
43
- disk.use.rjust(5).ljust(5).colorize(:green),
44
- ' ['.colorize(:blue),
45
- ('=' * (disk.use.to_i / 2)).colorize(:green),
46
- ' ' * (50 - disk.use.to_i / 2),
47
- ']'.colorize(:blue)
48
- ].join
49
-
50
- # Whole Thing
51
- puts [
52
- disk.mounted_on.ljust(pad_mount).colorize(:blue),
53
- disk[:size].to_s.ljust(pad_size).colorize(:magenta),
54
- disk.used.to_s.ljust(pad_used).colorize(:cyan),
55
- disk.avail.to_s.ljust(pad_avail).colorize(:white),
56
- use
57
- ].join
58
- end
19
+ GreenHat::Disk.df(args).each do |file|
20
+ puts GreenHat::Disk.format_output(file, true)
59
21
 
60
22
  # File End Loop / Break
61
23
  puts
62
24
  end
63
25
  end
64
- # rubocop:enable Metrics/MethodLength,Metrics/BlockLength
65
26
  # ------------------------------------------------------------------------
66
27
  end
67
28
  end
@@ -111,11 +111,8 @@ module GreenHat
111
111
  # --sort=count,fail,max,median,min,p95,p99,rps,score
112
112
  # ========================================================================
113
113
  def self.top(log_list)
114
- # Extract Args
115
- log_list, opts, args = ShellHelper.param_parse(log_list)
116
-
117
- # Default to color output
118
- args['color-output'] ||= true
114
+ # Extract Args, No Round / Truncate for Fast Stats
115
+ log_list, opts, args = ShellHelper.param_parse(log_list, %i[page round truncate])
119
116
 
120
117
  cmd = opts.map { |opt| "--#{opt.field}=#{opt.value}" }.join(' ')
121
118
  cmd += args.keys.map { |arg| "--#{arg}" }.join(' ')
@@ -142,8 +139,8 @@ module GreenHat
142
139
  # ===== [ Fast Stats - Errors ] ====================
143
140
  # ========================================================================
144
141
  def self.errors(log_list)
145
- # Extract Args
146
- log_list, opts, args = ShellHelper.param_parse(log_list)
142
+ # Extract Args, No Round / Truncate for Fast Stats
143
+ log_list, opts, args = ShellHelper.param_parse(log_list, %i[page round truncate])
147
144
 
148
145
  # Default to color output
149
146
  args['color-output'] ||= true
@@ -157,6 +154,8 @@ module GreenHat
157
154
  # Convert to Things
158
155
  files = ShellHelper.find_things(log_list)
159
156
 
157
+ LogBot.debug('FastStats CMD', cmd) if ENV['DEBUG']
158
+
160
159
  results = ShellHelper.file_process(files) do |file|
161
160
  [
162
161
  file.friendly_name,
@@ -0,0 +1,128 @@
1
+ module GreenHat
2
+ # CLI Helper
3
+ module ShellHelper
4
+ # Unify Filter / Filter Help
5
+ module Filter
6
+ # rubocop:disable Metrics/MethodLength
7
+ def self.help
8
+ puts "\u2500".colorize(:cyan) * 20
9
+ puts 'Filter'.colorize(:yellow)
10
+ puts "\u2500".colorize(:cyan) * 20
11
+
12
+ puts 'Options'.colorize(:blue)
13
+ puts '--raw'.colorize(:green)
14
+ puts ' Do not use less/paging'
15
+ puts
16
+
17
+ puts '--page'.colorize(:green)
18
+ puts ' Specifically enable or disable paging'
19
+ puts ' E.g. --page (default to true), --page=true, --page=false'
20
+ puts
21
+
22
+ puts '--round'.colorize(:green)
23
+ puts ' Attempt to round all integers. Default: 2.'
24
+ puts ' E.g. --round, --round=3, --round=0'
25
+ puts
26
+
27
+ puts '--truncate'.colorize(:green)
28
+ puts ' Limit output values. Defaults to 4 lines. 0 to disable'
29
+ puts ' E.g. --truncate=100 or --truncate=0'
30
+ puts
31
+
32
+ puts '--json'.colorize(:green)
33
+ puts ' Print output back into JSON'
34
+ puts
35
+
36
+ puts '--or'.colorize(:green)
37
+ puts ' Filters will use OR instead of AND'
38
+ puts
39
+
40
+ puts '--total'.colorize(:green)
41
+ puts ' Print only total count of matching entries'
42
+ puts
43
+
44
+ puts '--slice'.colorize(:green)
45
+ puts ' Extract specific fields from entries (slice multiple with comma)'
46
+ puts ' Ex: --slice=path or --slice=path,params'
47
+ puts
48
+
49
+ puts '--except'.colorize(:green)
50
+ puts ' Exclude specific fields (except multiple with comma)'
51
+ puts ' Ex: --except=params --except=params,path'
52
+ puts
53
+
54
+ puts '--stats'.colorize(:green)
55
+ puts ' Order/Count occurrances by field'
56
+ puts ' Ex: --stats=params --except=params,path'
57
+ puts
58
+
59
+ puts '--uniq'.colorize(:green)
60
+ puts ' Show unique values only'
61
+ puts ' Ex: --uniq=params --uniq=params,path'
62
+ puts
63
+
64
+ puts '--pluck'.colorize(:green)
65
+ puts ' Extract values from entries'
66
+ puts ' Ex: --pluck=params --pluck=params,path'
67
+ puts
68
+
69
+ puts '--archive'.colorize(:green)
70
+ puts ' Limit to specific archvie name (inclusive). Matching SOS tar.gz name'
71
+ puts ' Ex: --archive=dev-gitlab_20210622154626, --archive=202106,202107'
72
+ puts
73
+
74
+ puts '--sort'.colorize(:green)
75
+ puts ' Sort by multiple fields'
76
+ puts ' Ex: --sort=duration_s,db_duration_s'
77
+ puts
78
+
79
+ puts '--reverse'.colorize(:green)
80
+ puts ' Reverse all results'
81
+ puts ' Ex: --reverse'
82
+ puts
83
+
84
+ puts '--combine'.colorize(:green)
85
+ puts ' Omit archive identifier dividers. Useful with sort or time filters'
86
+ puts ' Ex: --combine'
87
+ puts
88
+
89
+ puts '--start'.colorize(:green)
90
+ puts ' Show events after specified time. Filtered by the `time` field'
91
+ puts ' Use with `--end` for between selections'
92
+ puts ' Ex: log filter --start="2021-06-22 14:44 UTC" --end="2021-06-22 14:45 UTC"'
93
+ puts
94
+
95
+ puts '--end'.colorize(:green)
96
+ puts ' Show events before specified time. Filtered by the `time` field'
97
+ puts ' Use with `--start` for between selections'
98
+ puts ' Ex: log filter --end="2021-06-22"'
99
+ puts
100
+
101
+ puts '--truncate'.colorize(:green)
102
+ puts ' Truncate field length. On by default (4 rows). Performance issues!'
103
+ puts ' Disable with --truncate=0'.colorize(:light_red)
104
+ puts ' Ex: --truncate=200, --truncate=2048"'
105
+ puts
106
+
107
+ puts 'Field Searching'.colorize(:blue)
108
+ puts ' --[key]=[value]'
109
+ puts ' Search in key for value'
110
+ puts ' Example: --path=mirror/pull'
111
+ puts
112
+
113
+ puts 'Search specific logs'.colorize(:blue)
114
+ puts ' Any non dash parameters will be the log list to search from'
115
+ puts " Ex: log filter --path=api sidekiq/current (hint: use `#{'ls'.colorize(:yellow)}` for log names"
116
+ puts
117
+
118
+ puts 'Example Queries'.colorize(:blue)
119
+ puts " Also see #{'examples'.colorize(:light_blue)} for even more examples"
120
+ puts ' log filter --class=BuildFinishedWorker sidekiq/current --slice=time,message'
121
+ puts ' log filter gitlab-rails/api_json.log --slice=ua --uniq=ua --ua=gitlab-runner'
122
+
123
+ puts
124
+ end
125
+ # rubocop:enable Metrics/MethodLength
126
+ end
127
+ end
128
+ end
@@ -3,7 +3,7 @@ module GreenHat
3
3
  # rubocop:disable Metrics/ModuleLength
4
4
  module ShellHelper
5
5
  # Generic Parameter Parsing
6
- def self.param_parse(params)
6
+ def self.param_parse(params, skip_args = [])
7
7
  # Turn Params into Hash
8
8
  opts = params.flat_map { |param| param_opt_scan(param) }
9
9
 
@@ -23,6 +23,9 @@ module GreenHat
23
23
  opt_field_remove?(opts, param) || arg_field_remove?(args, param)
24
24
  end
25
25
 
26
+ # Update other user defaults
27
+ Settings.default_log_args(args, skip_args)
28
+
26
29
  [params, opts, args]
27
30
  end
28
31
 
@@ -57,7 +60,7 @@ module GreenHat
57
60
 
58
61
  def self.param_special_opts
59
62
  %i[
60
- slice except stats uniq pluck round archive start end sort limit
63
+ slice except stats uniq pluck round archive start end sort limit truncate page case
61
64
  ]
62
65
  end
63
66
 
@@ -75,8 +78,10 @@ module GreenHat
75
78
  # Arg Defaults
76
79
  def self.param_arg_defaults(field)
77
80
  case field
78
- when :round then 2
81
+ when :round then [2]
79
82
  when :limit then [TTY::Screen.height / 2]
83
+ when :truncate then [TTY::Screen.width * 4]
84
+ when :page, :case then [:true]
80
85
  when *param_special_opts then []
81
86
  else
82
87
  true
@@ -128,15 +133,15 @@ module GreenHat
128
133
  end
129
134
 
130
135
  # Check if content needs to paged, or if auto_height is off
131
- if !args.page && count_rows(data)
132
- puts data.map { |x| entry_show(x, args) }.compact.join("\n")
136
+ if Page.skip?(args, data)
137
+ puts data.map { |entry| entry_show(args, entry) }.compact.join("\n")
133
138
  return true
134
139
  end
135
140
 
136
141
  # Default Pager
137
142
  TTY::Pager.page do |pager|
138
143
  data.each do |entry|
139
- output = entry_show(entry, args)
144
+ output = entry_show(args, entry)
140
145
 
141
146
  next if output.blank?
142
147
 
@@ -145,63 +150,76 @@ module GreenHat
145
150
  end
146
151
  end
147
152
 
148
- # Array/Hash and String pagination check. Don't unncessarily loop through everything
149
- def self.count_rows(data)
150
- height = TTY::Screen.height
151
- size = 0
152
-
153
- data.each do |entry|
154
- size += case entry
155
- when Hash then entry.keys.count
156
- when Array then entry.count
157
- else
158
- 1
159
- end
160
-
161
- break if size > height
162
- end
163
-
164
- height > size
165
- end
166
-
167
- # Entry Shower
168
- def self.entry_show(entry, args)
153
+ # Entry Shower / Top Level
154
+ def self.entry_show(args, entry, key = nil)
155
+ LogBot.debug('Entry Show', entry.class) if ENV['DEBUG']
169
156
  case entry
170
157
  when Hash then render_table(entry, args)
171
- when String, Float, Integer then entry
172
- when Array
173
- entry.map { |x| x.ai(multiline: false) }.join("\n")
158
+ when Float, Integer, Array
159
+ format_table_entry(args, entry, key)
160
+ # Ignore Special Formatting for Strings / Usually already formatted
161
+ when String
162
+ entry
174
163
  else
175
164
  LogBot.warn('Shell Show', "Unknown #{entry.class}")
176
165
  nil
177
166
  end
178
167
  end
179
168
 
169
+ # Format Table Entries
170
+ def self.format_table_entry(args, entry, key = nil)
171
+ formatted_entry = case entry
172
+ # Rounding
173
+ when Float, Integer || entry.numeric?
174
+ args.round ? entry.to_f.round(args.round.first.to_s.to_i).ai : entry.ai
175
+
176
+ # General Inspecting
177
+ when Hash then entry.ai(ruby19_syntax: true)
178
+
179
+ # Arrays often contain Hashes. Dangerous Recursive?
180
+ when Array
181
+ entry.map { |x| format_table_entry(args, x) }.join("\n")
182
+
183
+ when Time
184
+ entry.to_s.colorize(:light_white)
185
+
186
+ # Default String Formatting
187
+ else
188
+ StringColor.do(key, entry)
189
+ end
190
+
191
+ if args[:truncate]
192
+ entry_truncate(formatted_entry, args[:truncate].join.to_i)
193
+ else
194
+ formatted_entry
195
+ end
196
+ rescue StandardError => e
197
+ binding.pry
198
+ end
199
+
180
200
  # Print the Table in a Nice way
181
201
  def self.render_table(entry, args)
202
+ entry = entry.map { |k, v| [k, format_table_entry(args, v, k)] }.to_h
203
+ # Pre-format Entry
204
+
182
205
  table = TTY::Table.new(header: entry.keys, rows: [entry], orientation: :vertical)
183
206
 
184
- table.render(:unicode, padding: [0, 1, 0, 1]) do |renderer|
207
+ LogBot.debug('Rendering Entries') if ENV['DEBUG']
208
+ table.render(:unicode, padding: [0, 1, 0, 1], multiline: true) do |renderer|
185
209
  renderer.border.style = :cyan
186
210
 
187
- renderer.filter = lambda do |val, _row_index, col_index|
188
- if col_index == 1
189
- if val.numeric?
190
- # TODO: Better Casting?
191
- val = val.to_f.round(args.round.first.to_s.to_i) if args.round
192
- val.to_s.colorize(:blue)
193
- else
194
- val.to_s
195
- end
196
- else
197
- val.to_s
198
- end
199
- end
211
+ # renderer.filter = lambda do |val, _row_index, col_index|
212
+ # render_table_entry(val, col_index, args)
213
+ # end
200
214
  end
201
215
 
216
+ # LogBot.debug('Finish Render Table') if ENV['DEBUG']
202
217
  # Fall Back to Amazing Inspect
203
218
  rescue StandardError => e
204
- LogBot.warn('Table', message: e.message, backtrace: e.backtrace.first) if ENV['DEBUG']
219
+ if ENV['DEBUG']
220
+ LogBot.warn('Table', message: e.message)
221
+ ap e.backtrace
222
+ end
205
223
 
206
224
  [
207
225
  entry.ai,
@@ -210,6 +228,12 @@ module GreenHat
210
228
  ].join("\n")
211
229
  end
212
230
 
231
+ def self.render_table_entry(val, col_index, args)
232
+ return val.to_s unless col_index == 1
233
+
234
+ format_table_entry(args, val)
235
+ end
236
+
213
237
  # Main Entry Point for Filtering
214
238
  def self.filter_start(log_list, filter_type, args, opts)
215
239
  # Convert to Things
@@ -239,7 +263,7 @@ module GreenHat
239
263
  results = data.clone.flatten.compact
240
264
  results.select! do |row|
241
265
  opts.send(type) do |opt|
242
- filter_row_key(row, opt)
266
+ filter_row_key(row, opt, args)
243
267
  end
244
268
  end
245
269
 
@@ -273,9 +297,12 @@ module GreenHat
273
297
  # Pluck
274
298
  results = filter_pluck(results, args[:pluck]) if args.key?(:pluck)
275
299
 
300
+ # Truncate
301
+ # filter_truncate(results, args[:truncate].join.to_i) if args.key?(:truncate)
302
+
276
303
  # Limit
277
304
  if args[:limit]
278
- results[0..args[:limit].map(&:to_s).join.to_i]
305
+ results[0..args[:limit].map(&:to_s).join.to_i - 1]
279
306
  else
280
307
  results
281
308
  end
@@ -332,6 +359,17 @@ module GreenHat
332
359
  results.map { |row| row.except(*except) }
333
360
  end
334
361
 
362
+ def self.entry_truncate(entry, truncate)
363
+ # Ignore if Truncation Off
364
+ return entry if truncate.zero?
365
+
366
+ # Only truncate large strings
367
+ return entry unless entry.instance_of?(String) && entry.size > truncate
368
+
369
+ # Include '...' to indicate truncation
370
+ "#{entry.to_s[0..truncate]} #{'...'.colorize(:light_blue)}"
371
+ end
372
+
335
373
  def self.filter_slice(results, slice)
336
374
  # Avoid Empty Results
337
375
  if slice.empty?
@@ -397,13 +435,18 @@ module GreenHat
397
435
  end
398
436
 
399
437
  # Break out filter row logic into separate method
400
- def self.filter_row_key(row, param)
438
+ def self.filter_row_key(row, param, args)
401
439
  # Ignore Other Logic if Field isn't even included
402
440
  return false unless row.key? param.field
403
441
 
404
- # Not Included Param
405
- included = row[param.field].to_s.include? param.value.to_s
442
+ # Sensitivity Check / Check for Match
443
+ included = if args.key?(:case)
444
+ row[param.field].to_s.include? param.value.to_s
445
+ else
446
+ row[param.field].to_s.downcase.include? param.value.to_s.downcase
447
+ end
406
448
 
449
+ # Pivot of off include vs exclude
407
450
  if param.bang
408
451
  !included
409
452
  else
@@ -2,7 +2,6 @@ module GreenHat
2
2
  # CLI Helper
3
3
  module Shell
4
4
  # Logs
5
- # rubocop:disable Metrics/ModuleLength
6
5
  module Log
7
6
  def self.help
8
7
  puts "\u2500".colorize(:cyan) * 20
@@ -14,16 +13,17 @@ module GreenHat
14
13
  puts ' Just print selected logs'
15
14
  puts ' filter'.colorize(:green)
16
15
  puts ' Key/Field Filtering'
16
+ puts ' - See `filter_help`'
17
17
  puts ' search'.colorize(:green)
18
18
  puts ' General text by entry searching'
19
+ puts ' - See `search_help`'
19
20
  puts ' ls'.colorize(:green)
20
21
  puts ' List available files'
21
22
  puts
23
+ end
22
24
 
23
- filter_help
24
-
25
- puts
26
- search_help
25
+ def self.filter_help
26
+ ShellHelper::Filter.help
27
27
  end
28
28
 
29
29
  # List Files Helpers
@@ -113,122 +113,15 @@ module GreenHat
113
113
  end
114
114
 
115
115
  # log filter --path='cloud/gitlab-automation' --path='/pull' --all
116
- # cloud/gitlab-automation
117
116
  # log filter --project=thingy --other_filter=asdf *
118
117
  rescue StandardError => e
119
- binding.pry
120
118
  LogBot.fatal('Filter', message: e.message, backtrace: e.backtrace.first)
121
119
  end
122
120
  # ========================================================================
123
121
 
124
- # rubocop:disable Metrics/MethodLength
125
- def self.filter_help
126
- puts "\u2500".colorize(:cyan) * 20
127
- puts 'Log Filter'.colorize(:yellow)
128
- puts "\u2500".colorize(:cyan) * 20
129
-
130
- puts 'Options'.colorize(:blue)
131
- puts '--raw'.colorize(:green)
132
- puts ' Do not use less/paging'
133
- puts
134
-
135
- puts '--round'.colorize(:green)
136
- puts ' Attempt to round all integers. Default: 2.'
137
- puts ' E.g. --round, --round=3, --round=0'
138
- puts
139
-
140
- puts '--json'.colorize(:green)
141
- puts ' Print output back into JSON'
142
- puts
143
-
144
- puts '--or'.colorize(:green)
145
- puts ' Filters will use OR instead of AND'
146
- puts
147
-
148
- puts '--total'.colorize(:green)
149
- puts ' Print only total count of matching entries'
150
- puts
151
-
152
- puts '--slice'.colorize(:green)
153
- puts ' Extract specific fields from entries (slice multiple with comma)'
154
- puts ' Ex: --slice=path or --slice=path,params'
155
- puts
156
-
157
- puts '--except'.colorize(:green)
158
- puts ' Exclude specific fields (except multiple with comma)'
159
- puts ' Ex: --except=params --except=params,path'
160
- puts
161
-
162
- puts '--stats'.colorize(:green)
163
- puts ' Order/Count occurrances by field'
164
- puts ' Ex: --stats=params --except=params,path'
165
- puts
166
-
167
- puts '--uniq'.colorize(:green)
168
- puts ' Show unique values only'
169
- puts ' Ex: --uniq=params --uniq=params,path'
170
- puts
171
-
172
- puts '--pluck'.colorize(:green)
173
- puts ' Extract values from entries'
174
- puts ' Ex: --pluck=params --pluck=params,path'
175
- puts
176
-
177
- puts '--archive'.colorize(:green)
178
- puts ' Limit to specific archvie name (inclusive). Matching SOS tar.gz name'
179
- puts ' Ex: --archive=dev-gitlab_20210622154626, --archive=202106,202107'
180
- puts
181
-
182
- puts '--sort'.colorize(:green)
183
- puts ' Sort by multiple fields'
184
- puts ' Ex: --sort=duration_s,db_duration_s'
185
- puts
186
-
187
- puts '--reverse'.colorize(:green)
188
- puts ' Reverse all results'
189
- puts ' Ex: --reverse'
190
- puts
191
-
192
- puts '--combine'.colorize(:green)
193
- puts ' Omit archive identifier dividers. Useful with sort or time filters'
194
- puts ' Ex: --combine'
195
- puts
196
-
197
- puts '--start'.colorize(:green)
198
- puts ' Show events after specified time. Filtered by the `time` field'
199
- puts ' Use with `--end` for between selections'
200
- puts ' Ex: log filter --start="2021-06-22 14:44 UTC" --end="2021-06-22 14:45 UTC"'
201
- puts
202
-
203
- puts '--end'.colorize(:green)
204
- puts ' Show events before specified time. Filtered by the `time` field'
205
- puts ' Use with `--start` for between selections'
206
- puts ' Ex: log filter --end="2021-06-22"'
207
- puts
208
-
209
- puts 'Field Searching'.colorize(:blue)
210
- puts ' --[key]=[value]'
211
- puts ' Search in key for value'
212
- puts ' Example: --path=mirror/pull'
213
- puts
214
-
215
- puts 'Search specific logs'.colorize(:blue)
216
- puts ' Any non dash parameters will be the log list to search from'
217
- puts " Ex: log filter --path=api sidekiq/current (hint: use `#{'ls'.colorize(:yellow)}` for log names"
218
- puts
219
-
220
- puts 'Example Queries'.colorize(:blue)
221
- puts " Also see #{'filter_examples'.colorize(:light_blue)} for even more examples"
222
- puts 'log filter --class=BuildFinishedWorker sidekiq/current --slice=time,message'
223
- puts 'log filter gitlab-rails/api_json.log --slice=ua --uniq=ua --ua=gitlab-runner'
224
-
225
- puts
226
- end
227
- # rubocop:enable Metrics/MethodLength
228
-
229
122
  # rubocop:disable Layout/LineLength
230
123
  # TODO: Add a lot more examples
231
- def self.filter_examples
124
+ def self.examples
232
125
  puts 'Find `done` job for sidekiq, sort by duration, only duration, and show longest first'.colorize(:light_green)
233
126
  puts 'log filter sidekiq/current --job_status=done --sort=duration_s,db_duration_s --slice=duration_s,db_duration_s --reverse'
234
127
  puts
@@ -236,8 +129,8 @@ module GreenHat
236
129
  puts 'log filter --status=500 --slice=exception.message gitlab-rails/production_json.log'
237
130
  puts
238
131
  end
239
- # rubocop:enable Layout/LineLength
240
132
 
133
+ # rubocop:enable Layout/LineLength
241
134
  # ========================================================================
242
135
  # Search (Full Text / String Search)
243
136
  # ========================================================================
@@ -325,7 +218,7 @@ module GreenHat
325
218
 
326
219
  puts 'Search specific logs'.colorize(:blue)
327
220
  puts ' Any non dash parameters will be the log list to search from'
328
- puts " Ex: log filter --path=api sidekiq/current (hint: use `#{'ls'.colorize(:yellow)}` for log names"
221
+ puts " Ex: log filter --path=api sidekiq/current (hint: use `#{'ls'.colorize(:yellow)}` for log names)"
329
222
  puts
330
223
 
331
224
  puts 'Example Queries'.colorize(:blue)
@@ -337,7 +230,6 @@ module GreenHat
337
230
 
338
231
  # ------------------------------------------------------------------------
339
232
  end
340
- # rubocop:enable Metrics/ModuleLength
341
233
  end
342
234
  end
343
235
 
@@ -0,0 +1,33 @@
1
+ module GreenHat
2
+ module ShellHelper
3
+ # Helper to organize page check / methods
4
+ module Page
5
+ # Check if paging should be skipped
6
+ def self.skip?(args, data)
7
+ return count_rows(data) unless args[:page]
8
+
9
+ args[:page].first == :false
10
+ end
11
+
12
+ # Array/Hash and String pagination check. Don't unncessarily loop through everything
13
+ def self.count_rows(data)
14
+ height = TTY::Screen.height
15
+ size = 0
16
+
17
+ data.each do |entry|
18
+ size += case entry
19
+ when Hash then entry.keys.count
20
+ when Array then entry.count
21
+ else
22
+ 1
23
+ end
24
+
25
+ break if size > height
26
+ end
27
+
28
+ size < height
29
+ end
30
+ end
31
+ # ----
32
+ end
33
+ end
@@ -4,8 +4,51 @@ module GreenHat
4
4
  # Logs
5
5
  module Process
6
6
  # Easy Show All
7
- def self.ps
8
- ShellHelper.file_output GreenHat::Ps.ps
7
+ # Filter --archive
8
+
9
+ def self.ls
10
+ help
11
+ end
12
+
13
+ def self.help
14
+ puts "\u2500".colorize(:cyan) * 20
15
+ puts "#{'Process'.colorize(:yellow)} - ps helper"
16
+ puts "\u2500".colorize(:cyan) * 20
17
+
18
+ puts 'Command Summary'.colorize(:blue)
19
+ puts ' ps'.colorize(:green)
20
+ puts ' Raw `ps`'
21
+ puts ' filter'.colorize(:green)
22
+ puts ' Key/Field Filtering'
23
+ puts ' - See `filter_help`'
24
+ puts
25
+ end
26
+
27
+ def self.filter_help
28
+ ShellHelper::Filter.help
29
+ end
30
+
31
+ def self.ps(raw = {})
32
+ _log_list, _1opts, args = ShellHelper.param_parse(raw)
33
+ ShellHelper.file_output GreenHat::Ps.ps(args)
34
+ end
35
+
36
+ def self.filter(raw = {})
37
+ _log_list, opts, args = ShellHelper.param_parse(raw)
38
+
39
+ # AND / OR Filtering
40
+ filter_type = args.or ? :any? : :all?
41
+
42
+ # ShellHelper.file_output
43
+ results = ShellHelper.filter_start(GreenHat::Ps.ps(args).map(&:name), filter_type, args, opts)
44
+
45
+ # Check Search Results
46
+ if results.instance_of?(Hash) && results.values.flatten.empty?
47
+ puts 'No results'.colorize(:red)
48
+ else
49
+ # This causes the key 'colorized' output to also be included
50
+ ShellHelper.show(results.to_a.compact.flatten, args)
51
+ end
9
52
  end
10
53
  end
11
54
  end
@@ -19,7 +19,7 @@ module GreenHat
19
19
 
20
20
  attr_accessor :archive, :host, :os_release, :selinux_status, :cpu, :uname,
21
21
  :timedatectl, :uptime, :meminfo, :gitlab_manifest, :gitlab_status,
22
- :production_log, :api_log, :application_log, :sidekiq_log, :free_m
22
+ :production_log, :api_log, :application_log, :sidekiq_log, :free_m, :disk_free
23
23
 
24
24
  # Find Needed Files for Report
25
25
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
@@ -40,6 +40,7 @@ module GreenHat
40
40
  self.api_log = archive.things.find { |x| x.name == 'gitlab-rails/api_json.log' }
41
41
  self.application_log = archive.things.find { |x| x.name == 'gitlab-rails/application_json.log' }
42
42
  self.sidekiq_log = archive.things.find { |x| x.name == 'sidekiq/current' }
43
+ self.disk_free = archive.things.find { |x| x.name == 'df_h' }
43
44
  end
44
45
 
45
46
  def show
@@ -62,11 +63,17 @@ module GreenHat
62
63
  # Memory
63
64
  if meminfo || free_m
64
65
  output << 'Memory'.colorize(:light_yellow)
65
- output << memory if meminfo
66
+ output << memory_perc if meminfo
66
67
  output << memory_free if free_m
67
68
  output << ''
68
69
  end
69
70
 
71
+ # Disk
72
+ if disk_free
73
+ output << disks
74
+ output << ''
75
+ end
76
+
70
77
  # Gitlab
71
78
  output << 'GitLab'.colorize(:light_yellow) if gitlab_manifest
72
79
  output << gitlab_version if gitlab_manifest
@@ -209,6 +216,9 @@ module GreenHat
209
216
  end
210
217
 
211
218
  def sys_time
219
+ # Ignore if Empty
220
+ return false if timedatectl.data.nil?
221
+
212
222
  ntp_statuses = timedatectl.data.slice(*ntp_keys).values.compact
213
223
 
214
224
  enabled = %w[active yes] & ntp_statuses
@@ -260,26 +270,18 @@ module GreenHat
260
270
  ].join
261
271
  end
262
272
 
263
- def memory
273
+ def memory_perc
264
274
  total = ShellHelper.human_size_to_number(meminfo.data['MemTotal'])
265
275
  free = ShellHelper.human_size_to_number(meminfo.data['MemFree'])
266
276
  used = percent((total - free), total)
277
+
267
278
  [
268
- title('MemUsed'),
269
- '['.colorize(:light_black),
279
+ title('Usage'),
280
+ ' ['.colorize(:light_black),
270
281
  '='.colorize(:green) * (used / 2),
271
282
  ' ' * (50 - used / 2),
272
283
  ']'.colorize(:light_black),
273
- " #{100 - percent(free, total)}%".colorize(:green), # Inverse
274
- "\n",
275
- title('Total'),
276
- number_to_human_size(total).colorize(:light_white),
277
- "\n",
278
- title('Used'),
279
- number_to_human_size(total - free),
280
- "\n",
281
- title('Free'),
282
- number_to_human_size(free)
284
+ " #{100 - percent(free, total)}%".colorize(:green) # Inverse
283
285
  ].join
284
286
  end
285
287
 
@@ -288,6 +290,8 @@ module GreenHat
288
290
 
289
291
  return unless free
290
292
 
293
+ formatted_mem = free_m.data.map { |x| GreenHat::Memory.memory_row x }
294
+
291
295
  [
292
296
  title('Total', :cyan, 14),
293
297
  number_to_human_size(free.total.to_i * 1024**2),
@@ -299,7 +303,23 @@ module GreenHat
299
303
  number_to_human_size(free.free.to_i * 1024**2),
300
304
  "\n",
301
305
  title('Available', :green, 14),
302
- number_to_human_size(free.available.to_i * 1024**2)
306
+ number_to_human_size(free.available.to_i * 1024**2),
307
+ "\n\n",
308
+ formatted_mem.map { |x| x.prepend ' ' * 2 }.join("\n")
309
+ ].join
310
+ end
311
+
312
+ def disks
313
+ # GreenHat::Disk.df({archive: []})
314
+ file = GreenHat::Disk.df({ archive: [archive.name] })
315
+
316
+ disk_list = GreenHat::Disk.format_output(file.first, false, 3)
317
+
318
+ # Preapre / Indent List
319
+ [
320
+ 'Disks(Top % Usage)'.colorize(:light_yellow),
321
+ "\n",
322
+ disk_list.each { |x| x.prepend(' ' * 4) }.join("\n")
303
323
  ].join
304
324
  end
305
325
 
@@ -44,7 +44,7 @@ module GreenHat
44
44
  ]
45
45
  },
46
46
  'log/syslog' => {
47
- format: :bracket_log,
47
+ format: :syslog,
48
48
  log: true,
49
49
  pattern: [
50
50
  %r{log/syslog}
@@ -95,6 +95,13 @@ module GreenHat
95
95
  %r{consul/current}
96
96
  ]
97
97
  },
98
+ 'consul/failover_pgbouncer.log' => {
99
+ format: :raw,
100
+ log: true,
101
+ pattern: [
102
+ %r{consul/failover_pgbouncer.log}
103
+ ]
104
+ },
98
105
  'pgbouncer/current' => {
99
106
  format: :time_space,
100
107
  log: true,
@@ -179,6 +186,13 @@ module GreenHat
179
186
  %r{logrotate/current}
180
187
  ]
181
188
  },
189
+ 'gitlab-rails/grpc.log' => {
190
+ format: :raw,
191
+ log: true,
192
+ pattern: [
193
+ %r{gitlab-rails/grpc.log}
194
+ ]
195
+ },
182
196
  'gitlab-rails/api_json.log' => {
183
197
  format: :api_json,
184
198
  log: true,
@@ -301,6 +315,13 @@ module GreenHat
301
315
  /mount/
302
316
  ]
303
317
  },
318
+ 'nginx/current' => {
319
+ format: :time_space,
320
+ log: true,
321
+ pattern: [
322
+ %r{nginx/current}
323
+ ]
324
+ },
304
325
  'nginx/gitlab_pages_access.log' => {
305
326
  format: :raw,
306
327
  log: true,
@@ -671,7 +692,7 @@ module GreenHat
671
692
  ]
672
693
  },
673
694
  'log/messages' => {
674
- format: :raw,
695
+ format: :syslog,
675
696
  log: true,
676
697
  pattern: [
677
698
  %r{log/messages}
@@ -0,0 +1,39 @@
1
+ # Top
2
+ module GreenHat
3
+ # Log
4
+ module Formatters
5
+ # Formatters for bracket logs (dmesg, sos)
6
+ def format_syslog
7
+ self.result = raw.map do |row|
8
+ next if row.empty? || row == "\n"
9
+
10
+ format_syslog_row(row)
11
+ end
12
+
13
+ result.compact!
14
+ end
15
+
16
+ # Split / Parse Time
17
+ # TODO: Better sys log parsing? Cannot find ready-made regex/ruby parser
18
+ def format_syslog_row(row)
19
+ month, day, time, device, service, message = row.split(' ', 6)
20
+ service.gsub!(':', '')
21
+ pid = service.match(/\[(.*?)\]/)&.captures&.first
22
+
23
+ {
24
+ time: format_time_parse("#{month} #{day} #{time}"),
25
+ device: device,
26
+ service: service,
27
+ message: message,
28
+ pid: pid
29
+ }
30
+
31
+ # Return everything incase of error
32
+ rescue StandardError => e
33
+ LogBot.warn('SysLog Parse', e.message)
34
+ {
35
+ message: row
36
+ }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Table
5
+ # A module for calculating table data column widths
6
+ #
7
+ # Used by {Table} to manage column sizing.
8
+ #
9
+ # @api private
10
+ module Columns
11
+ # Converts column widths to array format or infers default widths
12
+ #
13
+ # @param [TTY::Table] table
14
+ #
15
+ # @param [Array, Numeric, NilClass] column_widths
16
+ #
17
+ # @return [Array[Integer]]
18
+ #
19
+ # @api public
20
+ def widths_from(table, column_widths = nil)
21
+ case column_widths
22
+ when Array
23
+ assert_widths(column_widths, table.columns_size)
24
+ Array(column_widths).map(&:to_i)
25
+ when Numeric
26
+ Array.new(table.columns_size, column_widths)
27
+ when NilClass
28
+ # THE SHIM! Strings that are too large fail to render correctly to do an empty result
29
+ # Set the maximum table width to half the screen size. Can't be full size due to the table headers
30
+ LogBot.debug('TTY Column Width') if ENV['DEBUG']
31
+ extract_widths(table.data).map { |x| x >= TTY::Screen.width ? (TTY::Screen.width * 3 / 4) : x }
32
+
33
+ else
34
+ raise TypeError, 'Invalid type for column widths'
35
+ end
36
+ end
37
+ module_function :widths_from
38
+ end
39
+ end
40
+ end
@@ -1,3 +1,3 @@
1
1
  module GreenHat
2
- VERSION = '0.1.5'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: greenhat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Davin Walker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-07-08 00:00:00.000000000 Z
11
+ date: 2021-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: amazing_print
@@ -399,14 +399,17 @@ files:
399
399
  - lib/greenhat/settings.rb
400
400
  - lib/greenhat/shell.rb
401
401
  - lib/greenhat/shell/cat.rb
402
+ - lib/greenhat/shell/color_string.rb
402
403
  - lib/greenhat/shell/disk.rb
403
404
  - lib/greenhat/shell/faststats.rb
405
+ - lib/greenhat/shell/filter.rb
404
406
  - lib/greenhat/shell/gitlab.rb
405
407
  - lib/greenhat/shell/help.rb
406
408
  - lib/greenhat/shell/helper.rb
407
409
  - lib/greenhat/shell/log.rb
408
410
  - lib/greenhat/shell/memory.rb
409
411
  - lib/greenhat/shell/network.rb
412
+ - lib/greenhat/shell/page.rb
410
413
  - lib/greenhat/shell/process.rb
411
414
  - lib/greenhat/shell/report.rb
412
415
  - lib/greenhat/thing.rb
@@ -425,6 +428,7 @@ files:
425
428
  - lib/greenhat/thing/formatters/multiline_json.rb
426
429
  - lib/greenhat/thing/formatters/raw.rb
427
430
  - lib/greenhat/thing/formatters/shellwords.rb
431
+ - lib/greenhat/thing/formatters/syslog.rb
428
432
  - lib/greenhat/thing/formatters/table.rb
429
433
  - lib/greenhat/thing/formatters/time_json.rb
430
434
  - lib/greenhat/thing/formatters/time_shellwords.rb
@@ -435,6 +439,7 @@ files:
435
439
  - lib/greenhat/thing/kind.rb
436
440
  - lib/greenhat/thing/spinner.rb
437
441
  - lib/greenhat/thing/super_log.rb
442
+ - lib/greenhat/tty/columns.rb
438
443
  - lib/greenhat/tty/custom_line.rb
439
444
  - lib/greenhat/tty/line.rb
440
445
  - lib/greenhat/tty/reader.rb