greenhat 0.3.1 → 0.3.5

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.
@@ -0,0 +1,344 @@
1
+ module GreenHat
2
+ # Root Level Shell / Report Helper
3
+ module Shell
4
+ def self.markdown_report(raw)
5
+ _files, flags, _args = Args.parse(raw)
6
+
7
+ archives = if flags.archive
8
+ Archive.all.select do |archive|
9
+ flags.archive.any? { |x| archive.name.include? x.to_s }
10
+ end
11
+ else
12
+ Archive.all
13
+ end
14
+
15
+ ShellHelper.show(archives.map(&:report_markdown).map(&:show).flatten, flags)
16
+ end
17
+ end
18
+ end
19
+
20
+ module GreenHat
21
+ # Report Generator Helper
22
+ # rubocop:disable Metrics/ClassLength
23
+ class ReportMarkdown
24
+ include ActionView::Helpers::NumberHelper
25
+
26
+ attr_accessor :archive, :host, :os_release, :selinux_status, :cpu, :uname,
27
+ :timedatectl, :uptime, :meminfo, :gitlab_manifest, :gitlab_status,
28
+ :production_log, :api_log, :application_log, :sidekiq_log,
29
+ :exceptions_log, :gitaly_log, :free_m, :disk_free
30
+
31
+ # Find Needed Files for Report
32
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
33
+ def initialize(archive)
34
+ self.archive = archive
35
+ self.host = archive.things.find { |x| x.name == 'hostname' }
36
+ self.os_release = archive.things.find { |x| x.name == 'etc/os-release' }
37
+ self.selinux_status = archive.things.find { |x| x.name == 'sestatus' }
38
+ self.cpu = archive.things.find { |x| x.name == 'lscpu' }
39
+ self.uname = archive.things.find { |x| x.name == 'uname' }
40
+ self.timedatectl = archive.things.find { |x| x.name == 'timedatectl' }
41
+ self.uptime = archive.things.find { |x| x.name == 'uptime' }
42
+ self.meminfo = archive.things.find { |x| x.name == 'meminfo' }
43
+ self.free_m = archive.things.find { |x| x.name == 'free_m' }
44
+ self.gitlab_manifest = archive.things.find { |x| x.name == 'gitlab/version-manifest.json' }
45
+ self.gitlab_status = archive.things.find { |x| x.name == 'gitlab_status' }
46
+ self.production_log = archive.things.find { |x| x.name == 'gitlab-rails/production_json.log' }
47
+ self.api_log = archive.things.find { |x| x.name == 'gitlab-rails/api_json.log' }
48
+ self.application_log = archive.things.find { |x| x.name == 'gitlab-rails/application_json.log' }
49
+ self.exceptions_log = archive.things.find { |x| x.name == 'gitlab-rails/exceptions_json.log' }
50
+ self.gitaly_log = archive.things.find { |x| x.name == 'gitaly/current' }
51
+ self.sidekiq_log = archive.things.find { |x| x.name == 'sidekiq/current' }
52
+ self.disk_free = archive.things.find { |x| x.name == 'df_h' }
53
+ end
54
+
55
+ def show
56
+ output = [
57
+ archive.friendly_name,
58
+ ''
59
+ ]
60
+
61
+ # OS
62
+ output << "**OS**\n"
63
+
64
+ output << collect_host
65
+ output << ''
66
+
67
+ # Memory
68
+ if meminfo || free_m
69
+ output << "**Memory**\n"
70
+ # output << memory_perc if meminfo
71
+ output << memory_free if free_m
72
+ output << ''
73
+ end
74
+
75
+ # Disk
76
+ if disk_free
77
+ output << disks
78
+ output << ''
79
+ end
80
+
81
+ # Gitlab
82
+ output << "**GitLab**\n" if gitlab_manifest || gitlab_status
83
+ output << gitlab_version if gitlab_manifest
84
+ output << gitlab_services if gitlab_status
85
+
86
+ output << ''
87
+
88
+ output << "**Errors**\n" if production_log || api_log || application_log || sidekiq_log
89
+ output << collect_errors
90
+
91
+ # Final Space / Return
92
+ output << ''
93
+ output
94
+ end
95
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
+
97
+ def collect_host
98
+ output = []
99
+ output << hostname if host
100
+ output << distro if os_release
101
+ output << selinux if selinux_status
102
+ # output << arch if cpu
103
+ output << kernel if uname
104
+ output << sys_time if timedatectl
105
+ output << sys_uptime if uptime
106
+ output << load_average if uptime && cpu
107
+
108
+ groups = output.each_slice((output.size / 2.to_f).round).to_a
109
+
110
+ table = TTY::Table.new do |t|
111
+ loop do
112
+ break if groups.all?(&:empty?)
113
+
114
+ t << groups.map(&:shift)
115
+ end
116
+ end
117
+
118
+ "```\n#{table.render(:basic, padding: [0, 2, 0, 0])}\n```"
119
+ end
120
+
121
+ def collect_errors
122
+ output = []
123
+ output << production_errors if production_log
124
+ output << application_errors if application_log
125
+ output << sidekiq_errors if sidekiq_log
126
+ output << api_errors if api_log
127
+ output << exception_errors if exceptions_log
128
+ output << gitaly_errors if gitaly_log
129
+
130
+ # Keep Alphabetical Sort / Allow for only one
131
+ slice_size = (output.size / 3.to_f).round
132
+ slice_size = 1 unless slice_size.positive?
133
+
134
+ groups = output.each_slice(slice_size).to_a
135
+
136
+ table = TTY::Table.new do |t|
137
+ loop do
138
+ break if groups.all?(&:empty?)
139
+
140
+ t << groups.map(&:shift)
141
+ end
142
+ end
143
+
144
+ "```\n#{table.render(:basic, padding: [0, 2, 0, 0])}\n```"
145
+ end
146
+
147
+ def exception_errors
148
+ count = exceptions_log.data.count
149
+
150
+ "Exception: #{count}"
151
+ end
152
+
153
+ def gitaly_errors
154
+ count = gitaly_log.data.count { |x| x.level == 'error' }
155
+
156
+ "Gitaly: #{count}"
157
+ end
158
+
159
+ def production_errors
160
+ count = production_log.data.count { |x| x.status == 500 }
161
+
162
+ "Production: #{count}"
163
+ end
164
+
165
+ def api_errors
166
+ count = api_log.data.count { |x| x.status == 500 }
167
+
168
+ "API: #{count}"
169
+ end
170
+
171
+ def application_errors
172
+ results = ShellHelper.filter_internal([
173
+ 'gitlab-rails/application_json.log',
174
+ '--message!="Cannot obtain an exclusive lease"',
175
+ '--severity=error'
176
+ ].join(' '))
177
+
178
+ "Application: #{results.count}"
179
+ end
180
+
181
+ def sidekiq_errors
182
+ count = sidekiq_log.data.count { |x| x&.severity == 'ERROR' }
183
+
184
+ "Sidekiq: #{count}"
185
+ end
186
+
187
+ def gitlab_services
188
+ [
189
+ "**Services**\n",
190
+ "\n",
191
+ GreenHat::GitLab.services_markdown(archive)
192
+ ].join
193
+ rescue StandardError => e
194
+ LogBot.fatal('GitLab Services', message: e.message, backtrace: e.backtrace.first)
195
+ end
196
+
197
+ def gitlab_version
198
+ "Version: #{gitlab_manifest.data.build_version}\n"
199
+ end
200
+
201
+ def hostname
202
+ "Hostname: #{host.data.first}"
203
+ end
204
+
205
+ def distro
206
+ [
207
+ "Distro: [#{os_release.data.ID}] ",
208
+ os_release.data.PRETTY_NAME
209
+ ].join
210
+ end
211
+
212
+ def selinux
213
+ status = selinux_status.data['SELinux status']
214
+
215
+ [
216
+ 'SeLinux: ',
217
+ status,
218
+ ' (',
219
+ selinux_status.data['Current mode'],
220
+ ')'
221
+ ].join
222
+ end
223
+
224
+ def arch
225
+ [
226
+ 'Arch: ',
227
+ cpu.data.Architecture
228
+ ].join
229
+ end
230
+
231
+ def kernel
232
+ # TODO: Better way to consistently get uname info?
233
+ value, build = uname.data.first.split[2].split('-')
234
+ [
235
+ 'Kernel: ',
236
+ value,
237
+ " (#{build})"
238
+ ].join
239
+ end
240
+
241
+ # Helper for finding if NTP is enabled
242
+ def ntp_keys
243
+ [
244
+ 'Network time on', 'NTP enabled', 'NTP service', 'System clock synchronized'
245
+ ]
246
+ end
247
+
248
+ def sys_time
249
+ # Ignore if Empty
250
+ return false if timedatectl.data.nil?
251
+
252
+ ntp_statuses = timedatectl.data.slice(*ntp_keys).values.compact
253
+
254
+ ntp_status = ntp_statuses.first
255
+
256
+ # Fall Back
257
+ ntp_status ||= 'unknown'
258
+
259
+ [
260
+ 'Sys Time: ',
261
+ timedatectl.data['Local time'],
262
+ "(ntp: #{ntp_status})"
263
+ ].join
264
+ end
265
+
266
+ # Strip/Simplify Uptime
267
+ def sys_uptime
268
+ init = uptime.data.first.split(', load average').first.strip
269
+
270
+ "Uptime: #{init.split('up ', 2).last}"
271
+ end
272
+
273
+ def load_average
274
+ cpu_count = cpu.data['CPU(s)'].to_i
275
+ intervals = uptime.data.first.split('load average: ', 2).last.split(', ').map(&:to_f)
276
+
277
+ # Generate Colorized Text for Output
278
+ intervals_text = intervals.map do |interval|
279
+ value = percent(interval, cpu_count)
280
+
281
+ "#{interval} (#{value}%)"
282
+ end
283
+
284
+ [
285
+ 'LoadAvg: ',
286
+ "[CPU #{cpu_count}] ",
287
+ intervals_text.join(', ')
288
+ ].join
289
+ end
290
+
291
+ def memory_free
292
+ free = free_m.data.find { |x| x.kind == 'Mem' }
293
+
294
+ return unless free
295
+
296
+ pad = 6
297
+ list = [
298
+ "#{title('Total', pad)} #{number_to_human_size(free.total.to_i * 1024**2)}",
299
+ "#{title('Used', pad)} #{number_to_human_size(free.used.to_i * 1024**2)}",
300
+ "#{title('Free', pad)} #{number_to_human_size(free.free.to_i * 1024**2)}",
301
+ "#{title('Avail', pad)} #{number_to_human_size(free.available.to_i * 1024**2)}"
302
+ ]
303
+
304
+ # Keep Alphabetical Sort
305
+ groups = list.each_slice((list.size / 2.to_f).round).to_a
306
+
307
+ table = TTY::Table.new do |t|
308
+ loop do
309
+ break if groups.all?(&:empty?)
310
+
311
+ t << groups.map(&:shift)
312
+ end
313
+ end
314
+
315
+ "```\n#{table.render(:basic, padding: [0, 2, 0, 0])}\n```"
316
+ end
317
+
318
+ def disks
319
+ # GreenHat::Disk.df({archive: []})
320
+ file = GreenHat::Disk.df({ archive: [archive.name] })
321
+
322
+ disk_list = GreenHat::Disk.markdown_format(file.first, false, 3)
323
+
324
+ # Preapre / Indent List
325
+ [
326
+ '**Disks**',
327
+ "\n\n```\n#{disk_list.join("\n")}\n```"
328
+ ].join
329
+ end
330
+
331
+ # ----------------------------
332
+ # Helpers
333
+ # ----------------------------
334
+ def percent(value, total)
335
+ ((value / total.to_f) * 100).round
336
+ end
337
+
338
+ # Helper to Make Cyan Titles
339
+ def title(name, ljust = 16)
340
+ "#{name}:".ljust(ljust)
341
+ end
342
+ end
343
+ # rubocop:enable Metrics/ClassLength
344
+ end
@@ -25,7 +25,8 @@ module GreenHat
25
25
 
26
26
  attr_accessor :archive, :host, :os_release, :selinux_status, :cpu, :uname,
27
27
  :timedatectl, :uptime, :meminfo, :gitlab_manifest, :gitlab_status,
28
- :production_log, :api_log, :application_log, :sidekiq_log, :free_m, :disk_free
28
+ :production_log, :api_log, :application_log, :sidekiq_log,
29
+ :exceptions_log, :gitaly_log, :free_m, :disk_free
29
30
 
30
31
  # Find Needed Files for Report
31
32
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
@@ -45,6 +46,8 @@ module GreenHat
45
46
  self.production_log = archive.things.find { |x| x.name == 'gitlab-rails/production_json.log' }
46
47
  self.api_log = archive.things.find { |x| x.name == 'gitlab-rails/api_json.log' }
47
48
  self.application_log = archive.things.find { |x| x.name == 'gitlab-rails/application_json.log' }
49
+ self.exceptions_log = archive.things.find { |x| x.name == 'gitlab-rails/exceptions_json.log' }
50
+ self.gitaly_log = archive.things.find { |x| x.name == 'gitaly/current' }
48
51
  self.sidekiq_log = archive.things.find { |x| x.name == 'sidekiq/current' }
49
52
  self.disk_free = archive.things.find { |x| x.name == 'df_h' }
50
53
  end
@@ -86,9 +89,11 @@ module GreenHat
86
89
  output << gitlab_services if gitlab_status
87
90
  output << title('Errors') if production_log || api_log || application_log || sidekiq_log
88
91
  output << production_errors if production_log
89
- output << api_errors if api_log
90
92
  output << application_errors if application_log
91
93
  output << sidekiq_errors if sidekiq_log
94
+ output << api_errors if api_log
95
+ output << exception_errors if exceptions_log
96
+ output << gitaly_errors if gitaly_log
92
97
 
93
98
  # Final Space / Return
94
99
  output << ''
@@ -96,6 +101,26 @@ module GreenHat
96
101
  end
97
102
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
98
103
 
104
+ def exception_errors
105
+ count = exceptions_log.data.count
106
+ color = count.zero? ? :green : :red
107
+
108
+ [
109
+ title(' Exception', :bright_red, 18),
110
+ count.to_s.pastel(color)
111
+ ].join
112
+ end
113
+
114
+ def gitaly_errors
115
+ count = gitaly_log.data.count { |x| x.level == 'error' }
116
+ color = count.zero? ? :green : :red
117
+
118
+ [
119
+ title(' Gitaly', :bright_red, 18),
120
+ count.to_s.pastel(color)
121
+ ].join
122
+ end
123
+
99
124
  def production_errors
100
125
  count = production_log.data.count { |x| x.status == 500 }
101
126
  color = count.zero? ? :green : :red
@@ -117,7 +142,13 @@ module GreenHat
117
142
  end
118
143
 
119
144
  def application_errors
120
- count = application_log.data.count { |x| x&.severity == 'ERROR' }
145
+ results = ShellHelper.filter_internal([
146
+ 'gitlab-rails/application_json.log',
147
+ '--message!="Cannot obtain an exclusive lease"',
148
+ '--severity=error'
149
+ ].join(' '))
150
+
151
+ count = results.count { |x| x&.severity == 'ERROR' }
121
152
  color = count.zero? ? :green : :red
122
153
 
123
154
  [
@@ -275,6 +306,7 @@ module GreenHat
275
306
  ].join
276
307
  end
277
308
 
309
+ # rubocop:disable Metrics/MethodLength
278
310
  def memory_free
279
311
  free = free_m.data.find { |x| x.kind == 'Mem' }
280
312
 
@@ -282,22 +314,39 @@ module GreenHat
282
314
 
283
315
  formatted_mem = free_m.data.map { |x| GreenHat::Memory.memory_row x }
284
316
 
285
- [
286
- title('Total', :cyan, 14),
287
- number_to_human_size(free.total.to_i * 1024**2),
288
- "\n",
289
- title('Used', :yellow, 14),
290
- number_to_human_size(free.used.to_i * 1024**2),
291
- "\n",
292
- title('Free', :blue, 14),
293
- number_to_human_size(free.free.to_i * 1024**2),
294
- "\n",
295
- title('Available', :green, 14),
296
- number_to_human_size(free.available.to_i * 1024**2),
297
- "\n\n",
298
- formatted_mem.map { |x| x.prepend ' ' * 2 }.join("\n")
299
- ].join
317
+ output = []
318
+ unless free.total.blank?
319
+ output << title('Total', :cyan, 14)
320
+ output << number_to_human_size(free.total.to_i * 1024**2)
321
+ output << "\n"
322
+ end
323
+
324
+ unless free.total.blank?
325
+ output << title('Used', :yellow, 14)
326
+ output << number_to_human_size(free.used.to_i * 1024**2)
327
+ output << "\n"
328
+ end
329
+
330
+ unless free.total.blank?
331
+ output << title('Free', :blue, 14)
332
+ output << number_to_human_size(free.free.to_i * 1024**2)
333
+ output << "\n"
334
+ end
335
+
336
+ unless free.total.blank?
337
+ output << title('Available', :green, 14)
338
+ output << number_to_human_size(free.available.to_i * 1024**2)
339
+ output << "\n"
340
+ end
341
+
342
+ output << "\n"
343
+ output << formatted_mem.map { |x| x.prepend ' ' * 2 }.join("\n")
344
+
345
+ output.join
346
+ rescue StandardError => e
347
+ LogBot.fatal('Memory', message: e.message, backtrace: e.backtrace.first)
300
348
  end
349
+ # rubocop:enable Metrics/MethodLength
301
350
 
302
351
  def disks
303
352
  # GreenHat::Disk.df({archive: []})
@@ -120,10 +120,12 @@ module GreenHat
120
120
  entry = entry.map { |k, v| [k, format_table_entry(flags, v, k)] }.to_h
121
121
  # Pre-format Entry
122
122
 
123
+ table_style = flags[:table_style]&.to_sym || :unicode
124
+
123
125
  table = TTY::Table.new(header: entry.keys, rows: [entry], orientation: :vertical)
124
126
 
125
127
  LogBot.debug('Rendering Entries') if ENV['DEBUG']
126
- table.render(:unicode, padding: [0, 1, 0, 1], multiline: true) do |renderer|
128
+ table.render(table_style, padding: [0, 1, 0, 1], multiline: true) do |renderer|
127
129
  renderer.border.style = :cyan
128
130
  end
129
131
 
@@ -148,8 +150,18 @@ module GreenHat
148
150
  format_table_entry(flags, val)
149
151
  end
150
152
 
153
+ # Internal Query Helper
154
+ # query = 'gitlab-rails/application_json.log --message!="Cannot obtain an exclusive lease" --severity=error'
155
+ # ShellHelper.filter_internal(query)
156
+ def self.filter_internal(search = '')
157
+ files, flags, args = Args.parse(Shellwords.split(search))
158
+ flags[:combine] = true
159
+
160
+ ShellHelper.filter_start(files, flags, args)
161
+ end
162
+
151
163
  # Main Entry Point for Filtering
152
- def self.filter_start(files, flags, args)
164
+ def self.filter_start(files, flags = {}, args = {})
153
165
  # Convert to Things
154
166
  logs = ShellHelper.find_things(files, flags).select(&:processed?)
155
167
 
@@ -182,9 +194,12 @@ module GreenHat
182
194
  # TODO: Simplify
183
195
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
184
196
  def self.filter(data, flags = {}, args = {})
185
- results = data.clone.flatten.compact
197
+ # results = data.clone.flatten.compact
198
+
199
+ # Experimenting with deep clone
200
+ results = Marshal.load(Marshal.dump(data))
186
201
  results.select! do |row|
187
- args.send(flags.logic) do |arg|
202
+ args.send(flags.logic || :all?) do |arg|
188
203
  filter_row_key(row, arg, flags)
189
204
  end
190
205
  end
@@ -192,6 +207,9 @@ module GreenHat
192
207
  # Ensure presecense of a specific field
193
208
  results = filter_exists(results, flags[:exists]) if flags.key?(:exists)
194
209
 
210
+ # Time Zone
211
+ results = filter_modify_timezone(results, flags[:time_zone]) if flags.key?(:time_zone)
212
+
195
213
  # Time Filtering
196
214
  results = filter_time(results, flags) if flags.key?(:start) || flags.key?(:end)
197
215
 
@@ -217,24 +235,34 @@ module GreenHat
217
235
  results.reverse! if flags[:reverse]
218
236
 
219
237
  # Count occurrences / Skip Results
220
- return filter_stats(results, flags[:stats]) if flags.key?(:stats)
238
+ return filter_stats(results, flags) if flags.key?(:stats)
239
+
240
+ # Limit before Pluck / Flattening
241
+ results = filter_limit(results, flags[:limit]) if flags.key?(:limit)
221
242
 
222
243
  # Pluck
223
244
  results = filter_pluck(results, flags[:pluck]) if flags.key?(:pluck)
224
245
 
225
- # Limit / Ensure Exists and Valid Number
226
- if flags.key?(:limit) && flags[:limit]
227
- # Old
228
- # results[0..flags[:limit].map(&:to_s).join.to_i - 1]
246
+ results
247
+ end
248
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
229
249
 
230
- # New
231
- results.shift flags[:limit]
250
+ # Limit / Ensure Exists and Valid Number
251
+ def self.filter_limit(results, limit)
252
+ return results unless limit.integer? && limit.positive?
232
253
 
233
- else
234
- results
254
+ results.shift limit
255
+ end
256
+
257
+ def self.filter_modify_timezone(results, time_zone)
258
+ results.each do |x|
259
+ next unless x.key? :time
260
+
261
+ x[:time] = x[:time].in_time_zone time_zone
235
262
  end
263
+
264
+ results
236
265
  end
237
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
238
266
 
239
267
  # Filter Start and End Times
240
268
  # rubocop:disable Metrics/MethodLength
@@ -339,7 +367,9 @@ module GreenHat
339
367
  end.inject(:&)
340
368
  end
341
369
 
342
- def self.filter_stats(results, stats)
370
+ def self.filter_stats(results, flags)
371
+ stats = flags[:stats]
372
+
343
373
  # Avoid Empty Results
344
374
  if stats.empty?
345
375
  filter_empty_arg('stats')
@@ -348,7 +378,7 @@ module GreenHat
348
378
 
349
379
  # Loop through Stats, Separate Hash/Tables
350
380
  stats.map do |field|
351
- occurrences = filter_count_occurrences(results, field)
381
+ occurrences = filter_count_occurrences(results, field, flags)
352
382
 
353
383
  # Total Occurences
354
384
  total = occurrences.values.sum
@@ -367,6 +397,13 @@ module GreenHat
367
397
  # Append Header / Total with field name
368
398
  output.unshift([field.to_s.pastel(:bright_black), total])
369
399
 
400
+ # Use Truncate For Long Keys
401
+ if flags[:truncate]
402
+ output.map! do |key, value|
403
+ [key.to_s[0..flags[:truncate]], value]
404
+ end
405
+ end
406
+
370
407
  # Format
371
408
  output.to_h
372
409
  end
@@ -378,10 +415,17 @@ module GreenHat
378
415
  end
379
416
 
380
417
  # Helper to Count occurrences
381
- def self.filter_count_occurrences(results, field)
418
+ def self.filter_count_occurrences(results, field, flags = {})
382
419
  results.each_with_object(Hash.new(0)) do |entry, counts|
383
420
  if entry.key? field
384
- counts[entry[field]] += 1
421
+ # Rounding in pagination breaks stats
422
+ key = if flags.key?(:round) && entry[field].numeric?
423
+ entry[field].to_f.round(flags.round)
424
+ else
425
+ entry[field]
426
+ end
427
+
428
+ counts[key] += 1
385
429
  else
386
430
  counts['None'.pastel(:bright_black)] += 1
387
431
  end
@@ -440,6 +484,32 @@ module GreenHat
440
484
  end
441
485
  end
442
486
 
487
+ # Total Count Helper
488
+ def self.fields_print(results)
489
+ results.each do |k, v|
490
+ puts k
491
+ puts field_table(v.map(&:keys).flatten.uniq.sort)
492
+ puts
493
+ end
494
+ end
495
+
496
+ def self.field_table(list, columns = 4)
497
+ return nil if list.size.zero?
498
+
499
+ # Keep Alphabetical Sort
500
+ groups = list.each_slice((list.size / columns.to_f).round).to_a
501
+
502
+ table = TTY::Table.new do |t|
503
+ loop do
504
+ break if groups.all?(&:empty?)
505
+
506
+ t << groups.map(&:shift)
507
+ end
508
+ end
509
+
510
+ table.render(:unicode, padding: [0, 1, 0, 1])
511
+ end
512
+
443
513
  # Unified Files Interface
444
514
  def self.files(file_list, base_list = nil, flags = {})
445
515
  base_list ||= Thing.all