logster 2.5.1 → 2.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -0
  3. data/CHANGELOG.md +9 -0
  4. data/README.md +15 -1
  5. data/Rakefile +1 -0
  6. data/assets/javascript/client-app.js +204 -168
  7. data/assets/javascript/vendor.js +5132 -5833
  8. data/assets/stylesheets/client-app.css +1 -1
  9. data/client-app/.eslintrc.js +17 -5
  10. data/client-app/.travis.yml +4 -3
  11. data/client-app/app/app.js +5 -7
  12. data/client-app/app/components/actions-menu.js +24 -17
  13. data/client-app/app/components/back-trace.js +148 -0
  14. data/client-app/app/components/env-tab.js +16 -12
  15. data/client-app/app/components/message-info.js +84 -7
  16. data/client-app/app/components/message-row.js +13 -15
  17. data/client-app/app/components/panel-resizer.js +63 -45
  18. data/client-app/app/components/patterns-list.js +6 -6
  19. data/client-app/app/components/update-time.js +13 -13
  20. data/client-app/app/controllers/index.js +4 -2
  21. data/client-app/app/index.html +1 -1
  22. data/client-app/app/initializers/app-init.js +1 -1
  23. data/client-app/app/lib/decorators.js +11 -0
  24. data/client-app/app/lib/preload.js +14 -3
  25. data/client-app/app/lib/utilities.js +63 -36
  26. data/client-app/app/models/group.js +6 -1
  27. data/client-app/app/models/message-collection.js +9 -7
  28. data/client-app/app/models/message.js +25 -20
  29. data/client-app/app/router.js +4 -6
  30. data/client-app/app/styles/app.css +18 -4
  31. data/client-app/app/templates/components/actions-menu.hbs +6 -2
  32. data/client-app/app/templates/components/back-trace.hbs +8 -0
  33. data/client-app/app/templates/components/message-info.hbs +7 -2
  34. data/client-app/app/templates/index.hbs +4 -1
  35. data/client-app/config/environment.js +1 -1
  36. data/client-app/config/optional-features.json +4 -1
  37. data/client-app/ember-cli-build.js +2 -3
  38. data/client-app/package-lock.json +9712 -2884
  39. data/client-app/package.json +25 -22
  40. data/client-app/preload-json-manager.rb +62 -0
  41. data/client-app/testem.js +0 -1
  42. data/client-app/tests/index.html +1 -1
  43. data/client-app/tests/integration/components/back-trace-test.js +109 -0
  44. data/client-app/tests/integration/components/message-info-test.js +4 -3
  45. data/client-app/tests/integration/components/patterns-list-test.js +7 -2
  46. data/lib/logster.rb +1 -0
  47. data/lib/logster/base_store.rb +16 -9
  48. data/lib/logster/configuration.rb +12 -2
  49. data/lib/logster/defer_logger.rb +1 -1
  50. data/lib/logster/logger.rb +12 -0
  51. data/lib/logster/message.rb +89 -30
  52. data/lib/logster/middleware/viewer.rb +44 -8
  53. data/lib/logster/redis_store.rb +69 -51
  54. data/lib/logster/suppression_pattern.rb +1 -1
  55. data/lib/logster/version.rb +1 -1
  56. data/logster.gemspec +1 -1
  57. data/test/logster/middleware/test_viewer.rb +100 -0
  58. data/test/logster/test_base_store.rb +16 -0
  59. data/test/logster/test_defer_logger.rb +1 -1
  60. data/test/logster/test_message.rb +142 -54
  61. data/test/logster/test_redis_store.rb +99 -39
  62. metadata +11 -6
@@ -38,7 +38,7 @@ module Logster
38
38
 
39
39
  elsif resource =~ /\/message\/([0-9a-f]+)$/
40
40
  if env[REQUEST_METHOD] != "DELETE"
41
- return method_not_allowed("DELETE is needed for /clear")
41
+ return method_not_allowed("DELETE")
42
42
  end
43
43
 
44
44
  key = $1
@@ -87,7 +87,7 @@ module Logster
87
87
 
88
88
  elsif resource =~ /\/clear$/
89
89
  if env[REQUEST_METHOD] != "POST"
90
- return method_not_allowed("POST is needed for /clear")
90
+ return method_not_allowed("POST")
91
91
  end
92
92
  Logster.store.clear
93
93
  return [200, {}, ["Messages cleared"]]
@@ -139,12 +139,12 @@ module Logster
139
139
 
140
140
  set_name = $1
141
141
  req = Rack::Request.new(env)
142
- return method_not_allowed if req.request_method == "GET"
142
+ return method_not_allowed(%w[POST PUT DELETE]) if req.request_method == "GET"
143
143
 
144
144
  update_patterns(set_name, req)
145
145
  elsif resource == "/reset-count.json"
146
146
  req = Rack::Request.new(env)
147
- return method_not_allowed("PUT is needed for this endpoint") if req.request_method != "PUT"
147
+ return method_not_allowed("PUT") if req.request_method != "PUT"
148
148
  pattern = nil
149
149
  if [true, "true"].include?(req.params["hard"])
150
150
  pattern = Logster.store.ignore.find do |patt|
@@ -170,6 +170,16 @@ module Logster
170
170
  else
171
171
  not_found
172
172
  end
173
+ elsif resource == '/solve-group'
174
+ return not_allowed unless Logster.config.enable_custom_patterns_via_ui
175
+ req = Rack::Request.new(env)
176
+ return method_not_allowed("POST") if req.request_method != "POST"
177
+ group = Logster.store.find_pattern_groups do |patt|
178
+ patt.inspect == req.params["regex"]
179
+ end.first
180
+ return not_found("No such pattern group exists") if !group
181
+ group.messages_keys.each { |k| Logster.store.solve(k) }
182
+ return [200, {}, []]
173
183
  else
174
184
  not_found
175
185
  end
@@ -243,7 +253,7 @@ module Logster
243
253
  when "DELETE"
244
254
  record.destroy
245
255
  else
246
- return method_not_allowed("Allowed methods: POST, PUT or DELETE")
256
+ return method_not_allowed(%w[POST PUT DELETE])
247
257
  end
248
258
 
249
259
  [200, { "Content-Type" => "application/json" }, [JSON.generate(pattern: record.to_s)]]
@@ -277,8 +287,11 @@ module Logster
277
287
  [403, {}, [message]]
278
288
  end
279
289
 
280
- def method_not_allowed(message = "Method not allowed")
281
- [405, {}, [message]]
290
+ def method_not_allowed(allowed_methods)
291
+ if Array === allowed_methods
292
+ allowed_methods = allowed_methods.join(", ")
293
+ end
294
+ [405, { "Allow" => allowed_methods }, []]
282
295
  end
283
296
 
284
297
  def parse_regex(string)
@@ -317,13 +330,36 @@ module Logster
317
330
  Rack::Utils.escape_html(JSON.fast_generate(payload))
318
331
  end
319
332
 
333
+ def preload_backtrace_data
334
+ gems_data = []
335
+ Gem::Specification.find_all do |gem|
336
+ url = gem.metadata["source_code_uri"] || gem.homepage
337
+ if url && url.match(/^https?:\/\/github.com\//)
338
+ gems_data << { name: gem.name, url: url }
339
+ end
340
+ end
341
+ {
342
+ gems_data: gems_data,
343
+ directories: Logster.config.project_directories
344
+ }
345
+ end
346
+
320
347
  def body(preload)
321
348
  root_url = @logs_path
322
349
  root_url += "/" if root_url[-1] != "/"
323
350
  preload.merge!(
324
351
  env_expandable_keys: Logster.config.env_expandable_keys,
325
- patterns_enabled: Logster.config.enable_custom_patterns_via_ui
352
+ patterns_enabled: Logster.config.enable_custom_patterns_via_ui,
353
+ application_version: Logster.config.application_version
326
354
  )
355
+ backtrace_links_enabled = Logster.config.enable_backtrace_links
356
+ gems_dir = Logster.config.gems_dir
357
+ gems_dir += "/" if gems_dir[-1] != "/"
358
+ preload.merge!(gems_dir: gems_dir, backtrace_links_enabled: backtrace_links_enabled)
359
+
360
+ if backtrace_links_enabled
361
+ preload.merge!(preload_backtrace_data)
362
+ end
327
363
  <<~HTML
328
364
  <!doctype html>
329
365
  <html>
@@ -6,6 +6,7 @@ require 'logster/redis_rate_limiter'
6
6
 
7
7
  module Logster
8
8
  class RedisStore < BaseStore
9
+ ENV_PREFIX = "logster-env-"
9
10
 
10
11
  attr_accessor :redis, :max_backlog, :redis_raw_connection
11
12
  attr_writer :redis_prefix
@@ -21,15 +22,14 @@ module Logster
21
22
  def save(message)
22
23
  if keys = message.solved_keys
23
24
  keys.each do |solved|
24
- return true if @redis.hget(solved_key, solved)
25
+ return false if @redis.hget(solved_key, solved)
25
26
  end
26
27
  end
27
- apply_max_size_limit(message)
28
28
 
29
29
  @redis.multi do
30
30
  @redis.hset(grouping_key, message.grouping_key, message.key)
31
31
  @redis.rpush(list_key, message.key)
32
- update_message(message)
32
+ update_message(message, save_env: true)
33
33
  end
34
34
 
35
35
  trim
@@ -46,7 +46,7 @@ module Logster
46
46
  save_pattern_group(group) if group.changed?
47
47
  end
48
48
  @redis.hdel(hash_key, msg.key)
49
- @redis.hdel(env_key, msg.key)
49
+ delete_env(msg.key)
50
50
  @redis.hdel(grouping_key, msg.grouping_key)
51
51
  @redis.lrem(list_key, -1, msg.key)
52
52
  end
@@ -60,26 +60,26 @@ module Logster
60
60
  save_pattern_group(group) if group.changed?
61
61
  end
62
62
  @redis.hdel(hash_key, message_keys)
63
- @redis.hdel(env_key, message_keys)
64
63
  @redis.hdel(grouping_key, grouping_keys)
65
64
  message_keys.each do |k|
66
65
  @redis.lrem(list_key, -1, k)
66
+ delete_env(k)
67
67
  end
68
68
  end
69
69
  end
70
70
 
71
- def replace_and_bump(message, save_env: true)
71
+ def replace_and_bump(message)
72
72
  # TODO make it atomic
73
73
  exists = @redis.hexists(hash_key, message.key)
74
74
  return false unless exists
75
75
 
76
76
  @redis.multi do
77
77
  @redis.hset(hash_key, message.key, message.to_json(exclude_env: true))
78
- @redis.hset(env_key, message.key, message.env_json) if save_env
78
+ push_env(message.key, message.env_buffer) if message.has_env_buffer?
79
79
  @redis.lrem(list_key, -1, message.key)
80
80
  @redis.rpush(list_key, message.key)
81
81
  end
82
-
82
+ message.env_buffer = [] if message.has_env_buffer?
83
83
  check_rate_limits(message.severity)
84
84
 
85
85
  true
@@ -164,22 +164,21 @@ module Logster
164
164
  def clear
165
165
  RedisRateLimiter.clear_all(@redis)
166
166
  @redis.del(solved_key)
167
+ all_keys = @redis.lrange(list_key, 0, -1)
167
168
  @redis.del(list_key)
168
- keys = @redis.smembers(protected_key) || []
169
- if keys.empty?
169
+ protected_keys = @redis.smembers(protected_key) || []
170
+ if protected_keys.empty?
170
171
  @redis.del(hash_key)
171
- @redis.del(env_key)
172
+ all_keys.each { |k| delete_env(k) }
172
173
  @redis.del(pattern_groups_key)
173
174
  @redis.del(grouping_key)
174
175
  else
175
- protected = @redis.mapped_hmget(hash_key, *keys)
176
- protected_env = @redis.mapped_hmget(env_key, *keys)
176
+ protected_messages = @redis.mapped_hmget(hash_key, *protected_keys)
177
177
  @redis.del(hash_key)
178
- @redis.del(env_key)
179
- @redis.mapped_hmset(hash_key, protected)
180
- @redis.mapped_hmset(env_key, protected_env)
178
+ @redis.mapped_hmset(hash_key, protected_messages)
179
+ (all_keys - protected_keys).each { |k| delete_env(k) }
181
180
 
182
- sorted = protected
181
+ sorted = protected_messages
183
182
  .values
184
183
  .map { |string|
185
184
  Message.from_json(string) rescue nil
@@ -203,10 +202,10 @@ module Logster
203
202
  # Delete everything, included protected messages
204
203
  # (use in tests)
205
204
  def clear_all
205
+ @redis.lrange(list_key, 0, -1).each { |k| delete_env(k) }
206
206
  @redis.del(list_key)
207
207
  @redis.del(protected_key)
208
208
  @redis.del(hash_key)
209
- @redis.del(env_key)
210
209
  @redis.del(grouping_key)
211
210
  @redis.del(solved_key)
212
211
  @redis.del(ignored_logs_count_key)
@@ -235,17 +234,35 @@ module Logster
235
234
  bulk_get(@redis.lrange(list_key, 0, -1), with_env: with_env)
236
235
  end
237
236
 
237
+ BULK_ENV_GET_LUA = <<~LUA
238
+ local results = {};
239
+ for i = 1, table.getn(KEYS), 1 do
240
+ results[i] = { KEYS[i], redis.call('LRANGE', KEYS[i], 0, -1) };
241
+ end
242
+ return results;
243
+ LUA
244
+
238
245
  def bulk_get(message_keys, with_env: true)
239
246
  return [] if !message_keys || message_keys.size == 0
240
- envs = @redis.mapped_hmget(env_key, *message_keys) if with_env
247
+ envs = nil
248
+ if with_env
249
+ envs = {}
250
+ @redis.eval(
251
+ BULK_ENV_GET_LUA,
252
+ keys: message_keys.map { |k| env_prefix(k) }
253
+ ).to_h.each do |k, v|
254
+ next if v.size == 0
255
+ parsed = v.size == 1 ? JSON.parse(v[0]) : v.map { |e| JSON.parse(e) }
256
+ envs[env_unprefix(k)] = parsed
257
+ end
258
+ end
241
259
  messages = @redis.hmget(hash_key, message_keys).map! do |json|
242
260
  next if !json || json.size == 0
243
261
  message = Message.from_json(json)
244
- if with_env
262
+ if with_env && envs
245
263
  env = envs[message.key]
246
264
  if !message.env || message.env.size == 0
247
- env = env && env.size > 0 ? ::JSON.parse(env) : {}
248
- message.env = env
265
+ message.env = env || {}
249
266
  end
250
267
  end
251
268
  message
@@ -255,20 +272,20 @@ module Logster
255
272
  end
256
273
 
257
274
  def get_env(message_key)
258
- json = @redis.hget(env_key, message_key)
259
- return if !json || json.size == 0
260
- JSON.parse(json)
275
+ envs = @redis.lrange(env_prefix(message_key), 0, -1)
276
+ return if !envs || envs.size == 0
277
+ envs.size == 1 ? JSON.parse(envs[0]) : envs.map { |j| JSON.parse(j) }
261
278
  end
262
279
 
263
280
  def protect(message_key)
264
- if message = get(message_key)
281
+ if message = get(message_key, load_env: false)
265
282
  message.protected = true
266
283
  update_message(message)
267
284
  end
268
285
  end
269
286
 
270
287
  def unprotect(message_key)
271
- if message = get(message_key)
288
+ if message = get(message_key, load_env: false)
272
289
  message.protected = false
273
290
  update_message(message)
274
291
  else
@@ -376,13 +393,11 @@ module Logster
376
393
 
377
394
  protected
378
395
 
379
- def clear_solved(count = nil)
380
-
396
+ def clear_solved
381
397
  ignores = Set.new(@redis.hkeys(solved_key) || [])
382
398
 
383
399
  if ignores.length > 0
384
- start = count ? 0 - count : 0
385
- message_keys = @redis.lrange(list_key, start, -1) || []
400
+ message_keys = @redis.lrange(list_key, 0, -1) || []
386
401
 
387
402
  bulk_get(message_keys).each do |message|
388
403
  unless (ignores & (message.solved_keys || [])).empty?
@@ -397,7 +412,7 @@ module Logster
397
412
  removed_keys = []
398
413
  while removed_key = @redis.lpop(list_key)
399
414
  unless @redis.sismember(protected_key, removed_key)
400
- rmsg = get removed_key
415
+ rmsg = get(removed_key, load_env: false)
401
416
  delete(rmsg)
402
417
  break
403
418
  else
@@ -410,9 +425,9 @@ module Logster
410
425
  end
411
426
  end
412
427
 
413
- def update_message(message)
428
+ def update_message(message, save_env: false)
414
429
  @redis.hset(hash_key, message.key, message.to_json(exclude_env: true))
415
- @redis.hset(env_key, message.key, message.env_json)
430
+ push_env(message.key, message.env) if save_env
416
431
  if message.protected
417
432
  @redis.sadd(protected_key, message.key)
418
433
  else
@@ -571,7 +586,7 @@ module Logster
571
586
  end
572
587
 
573
588
  def protected_key
574
- @saved_key ||= "__LOGSTER__SAVED"
589
+ @protected_key ||= "__LOGSTER__SAVED"
575
590
  end
576
591
 
577
592
  def grouping_key
@@ -608,22 +623,6 @@ module Logster
608
623
  )
609
624
  end
610
625
 
611
- def apply_max_size_limit(message)
612
- size = message.to_json(exclude_env: true).bytesize
613
- env_size = message.env_json.bytesize
614
- max_size = Logster.config.maximum_message_size_bytes
615
- if size + env_size > max_size
616
- # env is most likely the reason for the large size
617
- # truncate it so the overall size is < the limit
618
- if Array === message.env
619
- # the - 1 at the end ensures the size goes a little bit below the limit
620
- truncate_at = (message.env.size.to_f * max_size.to_f / (env_size + size)).to_i - 1
621
- truncate_at = 1 if truncate_at < 1
622
- message.env = message.env[0...truncate_at]
623
- end
624
- end
625
- end
626
-
627
626
  def register_rate_limit(severities, limit, duration, callback)
628
627
  severities = [severities] unless severities.is_a?(Array)
629
628
  redis = (@redis_raw_connection && @redis_prefix) ? @redis_raw_connection : @redis
@@ -636,5 +635,24 @@ module Logster
636
635
  rate_limits[self.redis_prefix] << rate_limiter
637
636
  rate_limiter
638
637
  end
638
+
639
+ def push_env(message_key, env)
640
+ prefixed = env_prefix(message_key)
641
+ env = [env] unless Array === env
642
+ @redis.lpush(prefixed, env.map(&:to_json).reverse)
643
+ @redis.ltrim(prefixed, 0, Logster.config.max_env_count_per_message - 1)
644
+ end
645
+
646
+ def delete_env(message_key)
647
+ @redis.del(env_prefix(message_key))
648
+ end
649
+
650
+ def env_unprefix(key)
651
+ key.sub(ENV_PREFIX, "")
652
+ end
653
+
654
+ def env_prefix(key)
655
+ ENV_PREFIX + key
656
+ end
639
657
  end
640
658
  end
@@ -24,7 +24,7 @@ module Logster
24
24
  def retro_delete_messages
25
25
  keys = []
26
26
  grouping_keys = []
27
- @store.get_all_messages.each do |message|
27
+ @store.get_all_messages(with_env: false).each do |message|
28
28
  if message =~ self.pattern
29
29
  keys << message.key
30
30
  grouping_keys << message.grouping_key
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Logster
4
- VERSION = "2.5.1"
4
+ VERSION = "2.6.0"
5
5
  end
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.add_development_dependency "guard"
33
33
  spec.add_development_dependency "guard-minitest"
34
34
  spec.add_development_dependency "timecop"
35
- spec.add_development_dependency "byebug"
35
+ spec.add_development_dependency "byebug", "~> 11.0.0"
36
36
  spec.add_development_dependency "rubocop", "~> 0.69.0"
37
37
  spec.add_development_dependency "rubocop-discourse"
38
38
  end
@@ -370,4 +370,104 @@ class TestViewer < Minitest::Test
370
370
  response = request.get("/logsie/fetch-env/123456abc.json")
371
371
  assert_equal(404, response.status)
372
372
  end
373
+
374
+ def test_solve_group_api_requires_post_request
375
+ Logster.config.enable_custom_patterns_via_ui = true
376
+ Logster::GroupingPattern.new(/gotta be post/).save
377
+ msg = Logster.store.report(
378
+ Logger::WARN,
379
+ '',
380
+ 'gotta be post 22',
381
+ env: { "application_version" => "abc" },
382
+ backtrace: "aa"
383
+ )
384
+ latest = Logster.store.latest
385
+ assert_equal(1, latest.size)
386
+ assert_equal(msg.key, latest.first["messages"].first.key)
387
+ %i[get head options].each do |m|
388
+ response = request.public_send(m, "/logsie/solve-group", params: { regex: "/gotta be post/" })
389
+ assert_equal(405, response.status)
390
+ assert_equal("POST", response.headers["Allow"])
391
+ end
392
+ latest = Logster.store.latest
393
+ assert_equal(1, latest.size)
394
+ assert_equal(msg.key, latest.first["messages"].first.key)
395
+ ensure
396
+ Logster.config.enable_custom_patterns_via_ui = false
397
+ end
398
+
399
+ def test_solve_group_returns_404_when_pattern_doesnt_exist
400
+ Logster.config.enable_custom_patterns_via_ui = true
401
+ Logster::GroupingPattern.new(/some pattern/).save
402
+ msg = Logster.store.report(
403
+ Logger::WARN,
404
+ '',
405
+ 'some pattern 22',
406
+ env: { "application_version" => "abc" },
407
+ backtrace: "aa"
408
+ )
409
+ latest = Logster.store.latest
410
+ assert_equal(1, latest.size)
411
+ assert_equal(msg.key, latest.first["messages"].first.key)
412
+ response = request.post("/logsie/solve-group", params: { regex: "/i dont exist/" })
413
+ assert_equal(404, response.status)
414
+ latest = Logster.store.latest
415
+ assert_equal(1, latest.size)
416
+ assert_equal(msg.key, latest.first["messages"].first.key)
417
+ ensure
418
+ Logster.config.enable_custom_patterns_via_ui = false
419
+ end
420
+
421
+ def test_solving_grouped_messages
422
+ Logster.config.enable_custom_patterns_via_ui = true
423
+ backtrace = "a b c d"
424
+ Logster::GroupingPattern.new(/test pattern/).save
425
+ msg1 = Logster.store.report(Logger::WARN, '', 'test pattern 1', backtrace: backtrace)
426
+ msg2 = Logster.store.report(
427
+ Logger::WARN,
428
+ '',
429
+ 'test pattern 2',
430
+ env: { "application_version" => "abc" },
431
+ backtrace: backtrace
432
+ )
433
+ msg3 = Logster.store.report(
434
+ Logger::WARN,
435
+ '',
436
+ 'test pattern 3',
437
+ env: [{ "application_version" => "def" }, { "application_version" => "ghi" }],
438
+ backtrace: backtrace
439
+ )
440
+ group = Logster.store.find_pattern_groups { |p| p == /test pattern/ }.first
441
+ assert_equal([msg3, msg2, msg1].map(&:key), group.messages_keys)
442
+
443
+ latest = Logster.store.latest
444
+ assert_equal(1, latest.size)
445
+ assert_equal([msg1, msg2, msg3].map(&:key).sort, latest.first["messages"].map(&:key).sort)
446
+
447
+ response = request.post("/logsie/solve-group", params: { regex: "/test pattern/" })
448
+ group = Logster.store.find_pattern_groups { |p| p == /test pattern/ }.first
449
+ assert_equal([msg1.key], group.messages_keys)
450
+ assert_equal(200, response.status)
451
+
452
+ latest = Logster.store.latest
453
+ # msg1 remains cause it doesn't have application_version
454
+ assert_equal([msg1.key], latest.first["messages"].map(&:key))
455
+ assert_equal(1, latest.size)
456
+
457
+ msg4 = Logster.store.report(Logger::WARN, '', 'test pattern 4', backtrace: backtrace)
458
+ %w[abc def ghi].each do |version|
459
+ Logster.store.report(
460
+ Logger::WARN,
461
+ '',
462
+ 'test pattern 5',
463
+ env: { "application_version" => version },
464
+ backtrace: backtrace
465
+ )
466
+ end
467
+ latest = Logster.store.latest
468
+ assert_equal([msg1.key, msg4.key].sort, latest.first["messages"].map(&:key).sort)
469
+ assert_equal(1, latest.size)
470
+ ensure
471
+ Logster.config.enable_custom_patterns_via_ui = false
472
+ end
373
473
  end