brainzlab 0.1.1 → 0.1.2

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -0
  3. data/lib/brainzlab/beacon/client.rb +209 -0
  4. data/lib/brainzlab/beacon/provisioner.rb +44 -0
  5. data/lib/brainzlab/beacon.rb +215 -0
  6. data/lib/brainzlab/configuration.rb +341 -3
  7. data/lib/brainzlab/cortex/cache.rb +59 -0
  8. data/lib/brainzlab/cortex/client.rb +141 -0
  9. data/lib/brainzlab/cortex/provisioner.rb +49 -0
  10. data/lib/brainzlab/cortex.rb +227 -0
  11. data/lib/brainzlab/dendrite/client.rb +232 -0
  12. data/lib/brainzlab/dendrite/provisioner.rb +44 -0
  13. data/lib/brainzlab/dendrite.rb +195 -0
  14. data/lib/brainzlab/devtools/assets/devtools.css +1106 -0
  15. data/lib/brainzlab/devtools/assets/devtools.js +322 -0
  16. data/lib/brainzlab/devtools/assets/logo.svg +6 -0
  17. data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +500 -0
  18. data/lib/brainzlab/devtools/assets/templates/error_page.html.erb +1086 -0
  19. data/lib/brainzlab/devtools/data/collector.rb +248 -0
  20. data/lib/brainzlab/devtools/middleware/asset_server.rb +63 -0
  21. data/lib/brainzlab/devtools/middleware/database_handler.rb +180 -0
  22. data/lib/brainzlab/devtools/middleware/debug_panel.rb +126 -0
  23. data/lib/brainzlab/devtools/middleware/error_page.rb +376 -0
  24. data/lib/brainzlab/devtools/renderers/debug_panel_renderer.rb +155 -0
  25. data/lib/brainzlab/devtools/renderers/error_page_renderer.rb +94 -0
  26. data/lib/brainzlab/devtools.rb +75 -0
  27. data/lib/brainzlab/flux/buffer.rb +96 -0
  28. data/lib/brainzlab/flux/client.rb +70 -0
  29. data/lib/brainzlab/flux/provisioner.rb +57 -0
  30. data/lib/brainzlab/flux.rb +174 -0
  31. data/lib/brainzlab/instrumentation/active_record.rb +18 -1
  32. data/lib/brainzlab/instrumentation/aws.rb +179 -0
  33. data/lib/brainzlab/instrumentation/dalli.rb +108 -0
  34. data/lib/brainzlab/instrumentation/excon.rb +152 -0
  35. data/lib/brainzlab/instrumentation/good_job.rb +102 -0
  36. data/lib/brainzlab/instrumentation/resque.rb +115 -0
  37. data/lib/brainzlab/instrumentation/solid_queue.rb +198 -0
  38. data/lib/brainzlab/instrumentation/stripe.rb +164 -0
  39. data/lib/brainzlab/instrumentation/typhoeus.rb +104 -0
  40. data/lib/brainzlab/instrumentation.rb +72 -0
  41. data/lib/brainzlab/nerve/client.rb +217 -0
  42. data/lib/brainzlab/nerve/provisioner.rb +44 -0
  43. data/lib/brainzlab/nerve.rb +219 -0
  44. data/lib/brainzlab/pulse/instrumentation.rb +35 -2
  45. data/lib/brainzlab/pulse/propagation.rb +1 -1
  46. data/lib/brainzlab/pulse/tracer.rb +1 -1
  47. data/lib/brainzlab/pulse.rb +1 -1
  48. data/lib/brainzlab/rails/log_subscriber.rb +1 -2
  49. data/lib/brainzlab/rails/railtie.rb +36 -3
  50. data/lib/brainzlab/recall/provisioner.rb +17 -0
  51. data/lib/brainzlab/recall.rb +6 -1
  52. data/lib/brainzlab/reflex.rb +2 -2
  53. data/lib/brainzlab/sentinel/client.rb +218 -0
  54. data/lib/brainzlab/sentinel/provisioner.rb +44 -0
  55. data/lib/brainzlab/sentinel.rb +165 -0
  56. data/lib/brainzlab/signal/client.rb +62 -0
  57. data/lib/brainzlab/signal/provisioner.rb +55 -0
  58. data/lib/brainzlab/signal.rb +136 -0
  59. data/lib/brainzlab/synapse/client.rb +290 -0
  60. data/lib/brainzlab/synapse/provisioner.rb +44 -0
  61. data/lib/brainzlab/synapse.rb +270 -0
  62. data/lib/brainzlab/utilities/circuit_breaker.rb +265 -0
  63. data/lib/brainzlab/utilities/health_check.rb +296 -0
  64. data/lib/brainzlab/utilities/log_formatter.rb +256 -0
  65. data/lib/brainzlab/utilities/rate_limiter.rb +230 -0
  66. data/lib/brainzlab/utilities.rb +17 -0
  67. data/lib/brainzlab/vault/cache.rb +80 -0
  68. data/lib/brainzlab/vault/client.rb +198 -0
  69. data/lib/brainzlab/vault/provisioner.rb +49 -0
  70. data/lib/brainzlab/vault.rb +268 -0
  71. data/lib/brainzlab/version.rb +1 -1
  72. data/lib/brainzlab/vision/client.rb +128 -0
  73. data/lib/brainzlab/vision/provisioner.rb +136 -0
  74. data/lib/brainzlab/vision.rb +157 -0
  75. data/lib/brainzlab.rb +101 -0
  76. metadata +60 -1
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module DevTools
5
+ module Middleware
6
+ class ErrorPage
7
+ def initialize(app)
8
+ @app = app
9
+ @renderer = Renderers::ErrorPageRenderer.new
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless should_handle?(env)
14
+
15
+ begin
16
+ status, headers, body = @app.call(env)
17
+
18
+ # Check if this is an error response that we should intercept
19
+ if status >= 400 && html_response?(headers) && !json_request?(env)
20
+ # Check if this looks like Rails' default error page
21
+ body_content = collect_body(body)
22
+ if body_content.include?("Action Controller: Exception caught") || body_content.include?("background: #C00")
23
+ # Extract exception info from the page
24
+ exception_info = extract_exception_from_html(body_content)
25
+ if exception_info
26
+ data = collect_debug_data_from_info(env, exception_info)
27
+ return render_error_page_from_info(exception_info, data)
28
+ end
29
+ end
30
+ end
31
+
32
+ [status, headers, body]
33
+ rescue Exception => exception
34
+ # Don't intercept if request wants JSON
35
+ return raise_exception(exception) if json_request?(env)
36
+
37
+ # Still capture to Reflex if available
38
+ capture_to_reflex(exception)
39
+
40
+ # Collect debug data and render branded error page
41
+ data = collect_debug_data(env, exception)
42
+ render_error_page(exception, data)
43
+ end
44
+ end
45
+
46
+ def html_response?(headers)
47
+ # Handle both uppercase and lowercase header names
48
+ content_type = headers["Content-Type"] || headers["content-type"] || ""
49
+ content_type.to_s.downcase.include?("text/html")
50
+ end
51
+
52
+ def extract_exception_from_html(body)
53
+ # Try to extract exception class and message from Rails error page
54
+ if match = body.match(/<h1>([^<]+)<\/h1>/)
55
+ error_title = match[1]
56
+ # Extract the exception message from the page
57
+ if msg_match = body.match(/<pre[^>]*>([^<]+)<\/pre>/)
58
+ error_message = msg_match[1]
59
+ end
60
+
61
+ # Try to extract backtrace from Rails 8 format
62
+ # Format: <a class="trace-frames ...">path/to/file.rb:123:in 'method'</a>
63
+ backtrace = []
64
+ body.scan(/<a[^>]*class="trace-frames[^"]*"[^>]*>\s*([^<]+)\s*<\/a>/m) do |trace_match|
65
+ line = trace_match[0].strip
66
+ # Decode HTML entities
67
+ line = line.gsub("&#39;", "'").gsub("&quot;", '"').gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">")
68
+ backtrace << line unless line.empty?
69
+ end
70
+
71
+ {
72
+ class_name: decode_html_entities(error_title.strip),
73
+ message: decode_html_entities(error_message&.strip || error_title.strip),
74
+ backtrace: backtrace
75
+ }
76
+ end
77
+ end
78
+
79
+ def decode_html_entities(str)
80
+ return str unless str
81
+ str.gsub("&#39;", "'")
82
+ .gsub("&quot;", '"')
83
+ .gsub("&amp;", "&")
84
+ .gsub("&lt;", "<")
85
+ .gsub("&gt;", ">")
86
+ .gsub("&nbsp;", " ")
87
+ end
88
+
89
+ def collect_debug_data_from_info(env, info)
90
+ context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
91
+ collector_data = Data::Collector.get_request_data
92
+
93
+ backtrace = (info[:backtrace] || []).map do |line|
94
+ parsed = parse_backtrace_line(line)
95
+ parsed[:in_app] = in_app_frame?(parsed[:file])
96
+ parsed
97
+ end
98
+
99
+ # Extract source from the first in-app frame
100
+ source_extract = extract_source_from_backtrace(info[:backtrace] || [])
101
+
102
+ {
103
+ exception: nil,
104
+ exception_class: info[:class_name],
105
+ exception_message: info[:message],
106
+ backtrace: backtrace,
107
+ request: build_request_info(env, context),
108
+ context: build_context_info(context),
109
+ sql_queries: collector_data.dig(:database, :queries) || [],
110
+ environment: collect_environment_info,
111
+ source_extract: source_extract
112
+ }
113
+ end
114
+
115
+ def render_error_page_from_info(info, data)
116
+ # Create a simple exception-like object
117
+ exception = StandardError.new(info[:message])
118
+ exception.define_singleton_method(:class) do
119
+ Class.new(StandardError) do
120
+ define_singleton_method(:name) { info[:class_name] }
121
+ end
122
+ end
123
+
124
+ data[:exception] = exception
125
+ html = @renderer.render(exception, data)
126
+
127
+ [
128
+ 500,
129
+ {
130
+ "Content-Type" => "text/html; charset=utf-8",
131
+ "Content-Length" => html.bytesize.to_s,
132
+ "X-Content-Type-Options" => "nosniff"
133
+ },
134
+ [html]
135
+ ]
136
+ end
137
+
138
+ private
139
+
140
+ def collect_body(body)
141
+ full_body = +""
142
+ body.each { |part| full_body << part }
143
+ body.close if body.respond_to?(:close)
144
+ full_body
145
+ end
146
+
147
+ def should_handle?(env)
148
+ return false unless DevTools.error_page_enabled?
149
+ return false unless DevTools.allowed_environment?
150
+ return false unless DevTools.allowed_ip?(extract_ip(env))
151
+
152
+ true
153
+ end
154
+
155
+ def extract_ip(env)
156
+ forwarded = env["HTTP_X_FORWARDED_FOR"]
157
+ return forwarded.split(",").first.strip if forwarded
158
+
159
+ env["REMOTE_ADDR"]
160
+ end
161
+
162
+ def json_request?(env)
163
+ accept = env["HTTP_ACCEPT"] || ""
164
+ content_type = env["CONTENT_TYPE"] || ""
165
+
166
+ accept.include?("application/json") ||
167
+ content_type.include?("application/json") ||
168
+ env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest"
169
+ end
170
+
171
+ def capture_to_reflex(exception)
172
+ return unless defined?(BrainzLab::Reflex)
173
+
174
+ BrainzLab::Reflex.capture(exception)
175
+ rescue StandardError
176
+ # Ignore errors in error capturing
177
+ end
178
+
179
+ def raise_exception(exception)
180
+ raise exception
181
+ end
182
+
183
+ def collect_debug_data(env, exception)
184
+ context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
185
+ collector_data = Data::Collector.get_request_data
186
+
187
+ {
188
+ exception: exception,
189
+ backtrace: format_backtrace(exception),
190
+ request: build_request_info(env, context),
191
+ context: build_context_info(context),
192
+ sql_queries: collector_data.dig(:database, :queries) || [],
193
+ environment: collect_environment_info,
194
+ source_extract: extract_source_lines(exception)
195
+ }
196
+ end
197
+
198
+ def build_request_info(env, context)
199
+ request = defined?(ActionDispatch::Request) ? ActionDispatch::Request.new(env) : nil
200
+
201
+ {
202
+ method: request&.request_method || env["REQUEST_METHOD"],
203
+ path: request&.path || env["PATH_INFO"],
204
+ url: request&.url || env["REQUEST_URI"],
205
+ params: scrub_params(context&.request_params || extract_params(env)),
206
+ headers: extract_headers(env),
207
+ session: {}
208
+ }
209
+ end
210
+
211
+ def build_context_info(context)
212
+ {
213
+ controller: context&.controller,
214
+ action: context&.action,
215
+ request_id: context&.request_id,
216
+ user: context&.user
217
+ }
218
+ end
219
+
220
+ def extract_params(env)
221
+ return {} unless defined?(Rack::Request)
222
+
223
+ Rack::Request.new(env).params
224
+ rescue StandardError
225
+ {}
226
+ end
227
+
228
+ def extract_headers(env)
229
+ headers = {}
230
+ env.each do |key, value|
231
+ if key.start_with?("HTTP_")
232
+ header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
233
+ headers[header_name] = value
234
+ end
235
+ end
236
+ headers
237
+ end
238
+
239
+ def scrub_params(params)
240
+ return {} unless params.is_a?(Hash)
241
+
242
+ scrub_fields = BrainzLab.configuration.scrub_fields.map(&:to_s)
243
+
244
+ params.transform_values.with_index do |(key, value), _|
245
+ if scrub_fields.include?(key.to_s.downcase)
246
+ "[FILTERED]"
247
+ elsif value.is_a?(Hash)
248
+ scrub_params(value)
249
+ else
250
+ value
251
+ end
252
+ end
253
+ rescue StandardError
254
+ params
255
+ end
256
+
257
+ def format_backtrace(exception)
258
+ (exception.backtrace || []).first(50).map do |line|
259
+ parsed = parse_backtrace_line(line)
260
+ parsed[:in_app] = in_app_frame?(parsed[:file])
261
+ parsed
262
+ end
263
+ end
264
+
265
+ def parse_backtrace_line(line)
266
+ match = line.match(/\A(.+):(\d+)(?::in `(.+)')?/)
267
+ return { file: line, line: 0, function: nil, raw: line } unless match
268
+
269
+ {
270
+ file: match[1],
271
+ line: match[2].to_i,
272
+ function: match[3],
273
+ raw: line
274
+ }
275
+ end
276
+
277
+ def in_app_frame?(file)
278
+ return false unless file
279
+
280
+ file.include?("/app/") && !file.include?("/vendor/") && !file.include?("/gems/")
281
+ end
282
+
283
+ def extract_source_from_backtrace(backtrace_lines)
284
+ return nil if backtrace_lines.empty?
285
+
286
+ # Find the first in-app frame
287
+ target_line = backtrace_lines.find { |line| in_app_frame?(line.split(":").first) }
288
+ target_line ||= backtrace_lines.first
289
+
290
+ match = target_line.match(/\A(.+):(\d+)/)
291
+ return nil unless match
292
+
293
+ file = match[1]
294
+ line_number = match[2].to_i
295
+ return nil unless File.exist?(file)
296
+
297
+ lines = File.readlines(file)
298
+ start_line = [line_number - 6, 0].max
299
+ end_line = [line_number + 4, lines.length - 1].min
300
+
301
+ {
302
+ file: file,
303
+ line_number: line_number,
304
+ lines: lines[start_line..end_line].map.with_index do |content, idx|
305
+ {
306
+ number: start_line + idx + 1,
307
+ content: content.chomp,
308
+ highlight: (start_line + idx + 1) == line_number
309
+ }
310
+ end
311
+ }
312
+ rescue StandardError
313
+ nil
314
+ end
315
+
316
+ def extract_source_lines(exception)
317
+ return nil unless exception.backtrace&.any?
318
+
319
+ # Find the first in-app frame (application code, not gems/framework)
320
+ target_line = exception.backtrace.find { |line| in_app_frame?(line.split(":").first) }
321
+ # Fall back to first frame if no in-app frame found
322
+ target_line ||= exception.backtrace.first
323
+
324
+ match = target_line.match(/\A(.+):(\d+)/)
325
+ return nil unless match
326
+
327
+ file = match[1]
328
+ line_number = match[2].to_i
329
+ return nil unless File.exist?(file)
330
+
331
+ lines = File.readlines(file)
332
+ start_line = [line_number - 6, 0].max
333
+ end_line = [line_number + 4, lines.length - 1].min
334
+
335
+ {
336
+ file: file,
337
+ line_number: line_number,
338
+ lines: lines[start_line..end_line].map.with_index do |content, idx|
339
+ {
340
+ number: start_line + idx + 1,
341
+ content: content.chomp,
342
+ highlight: (start_line + idx + 1) == line_number
343
+ }
344
+ end
345
+ }
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
350
+ def collect_environment_info
351
+ {
352
+ rails_version: defined?(Rails::VERSION::STRING) ? Rails::VERSION::STRING : "N/A",
353
+ ruby_version: RUBY_VERSION,
354
+ env: BrainzLab.configuration.environment,
355
+ server: ENV["SERVER_SOFTWARE"] || "Unknown",
356
+ pid: Process.pid
357
+ }
358
+ end
359
+
360
+ def render_error_page(exception, data)
361
+ html = @renderer.render(exception, data)
362
+
363
+ [
364
+ 500,
365
+ {
366
+ "Content-Type" => "text/html; charset=utf-8",
367
+ "Content-Length" => html.bytesize.to_s,
368
+ "X-Content-Type-Options" => "nosniff"
369
+ },
370
+ [html]
371
+ ]
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "cgi"
5
+ require "json"
6
+
7
+ module BrainzLab
8
+ module DevTools
9
+ module Renderers
10
+ class DebugPanelRenderer
11
+ def initialize
12
+ @template_path = File.join(DevTools::ASSETS_PATH, "templates", "debug_panel.html.erb")
13
+ # Cache compiled ERB template to avoid file I/O on every request
14
+ @cached_erb = nil
15
+ @template_mtime = nil
16
+ end
17
+
18
+ def render(data)
19
+ erb = cached_erb
20
+
21
+ # Make data available to template
22
+ @data = data
23
+ @timing = data[:timing] || {}
24
+ @request = data[:request] || {}
25
+ @controller = data[:controller] || {}
26
+ @response = data[:response] || {}
27
+ @database = data[:database] || {}
28
+ @views = data[:views] || {}
29
+ @logs = data[:logs] || []
30
+ @memory = data[:memory] || {}
31
+ @user = data[:user]
32
+ @breadcrumbs = data[:breadcrumbs] || []
33
+ @expand_by_default = DevTools.expand_by_default?
34
+ @panel_position = DevTools.panel_position
35
+
36
+ erb.result(binding)
37
+ end
38
+
39
+ private
40
+
41
+ def h(text)
42
+ CGI.escapeHTML(text.to_s)
43
+ end
44
+
45
+ def asset_url(file)
46
+ "#{DevTools.asset_path}/#{file}"
47
+ end
48
+
49
+ def json_pretty(obj)
50
+ return "" if obj.nil? || (obj.respond_to?(:empty?) && obj.empty?)
51
+
52
+ JSON.pretty_generate(obj)
53
+ rescue StandardError
54
+ obj.inspect
55
+ end
56
+
57
+ def truncate(text, length = 80)
58
+ return "" unless text
59
+
60
+ text = text.to_s
61
+ text.length > length ? "#{text[0...length]}..." : text
62
+ end
63
+
64
+ def format_duration(ms)
65
+ return "0ms" unless ms
66
+
67
+ if ms >= 1000
68
+ "#{(ms / 1000.0).round(2)}s"
69
+ else
70
+ "#{ms.round(2)}ms"
71
+ end
72
+ end
73
+
74
+ def duration_class(ms)
75
+ return "" unless ms
76
+
77
+ if ms > 1000
78
+ "very-slow"
79
+ elsif ms > 500
80
+ "slow"
81
+ elsif ms > 200
82
+ "moderate"
83
+ else
84
+ ""
85
+ end
86
+ end
87
+
88
+ def query_duration_class(ms)
89
+ return "" unless ms
90
+
91
+ if ms > 100
92
+ "very-slow"
93
+ elsif ms > 50
94
+ "slow"
95
+ elsif ms > 10
96
+ "moderate"
97
+ else
98
+ ""
99
+ end
100
+ end
101
+
102
+ def status_class(status)
103
+ case status
104
+ when 200..299 then "success"
105
+ when 300..399 then "redirect"
106
+ when 400..499 then "client-error"
107
+ when 500..599 then "server-error"
108
+ else ""
109
+ end
110
+ end
111
+
112
+ def log_level_class(level)
113
+ case level.to_s.downcase
114
+ when "error", "fatal" then "error"
115
+ when "warn", "warning" then "warning"
116
+ when "info" then "info"
117
+ when "debug" then "debug"
118
+ else ""
119
+ end
120
+ end
121
+
122
+ def format_timestamp(time)
123
+ return "" unless time
124
+
125
+ time.strftime("%H:%M:%S.%L")
126
+ end
127
+
128
+ def memory_class(delta_mb)
129
+ return "" unless delta_mb
130
+
131
+ if delta_mb > 50
132
+ "high"
133
+ elsif delta_mb > 20
134
+ "moderate"
135
+ else
136
+ ""
137
+ end
138
+ end
139
+
140
+ # Cache compiled ERB template, reloading only if file changed (dev mode)
141
+ def cached_erb
142
+ current_mtime = File.mtime(@template_path) rescue nil
143
+
144
+ if @cached_erb.nil? || (current_mtime && current_mtime != @template_mtime)
145
+ template = File.read(@template_path)
146
+ @cached_erb = ERB.new(template, trim_mode: "-")
147
+ @template_mtime = current_mtime
148
+ end
149
+
150
+ @cached_erb
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "cgi"
5
+
6
+ module BrainzLab
7
+ module DevTools
8
+ module Renderers
9
+ class ErrorPageRenderer
10
+ def initialize
11
+ @template_path = File.join(DevTools::ASSETS_PATH, "templates", "error_page.html.erb")
12
+ @cached_erb = nil
13
+ @template_mtime = nil
14
+ end
15
+
16
+ def render(exception, data)
17
+ erb = cached_erb
18
+
19
+ # Make data available to template
20
+ @exception = exception
21
+ @data = data
22
+ @backtrace = data[:backtrace] || []
23
+ @request = data[:request] || {}
24
+ @context = data[:context] || {}
25
+ @sql_queries = data[:sql_queries] || []
26
+ @environment = data[:environment] || {}
27
+ @source_extract = data[:source_extract]
28
+
29
+ erb.result(binding)
30
+ end
31
+
32
+ private
33
+
34
+ def h(text)
35
+ CGI.escapeHTML(text.to_s)
36
+ end
37
+
38
+ def asset_url(file)
39
+ "#{DevTools.asset_path}/#{file}"
40
+ end
41
+
42
+ def format_params(params, indent = 0)
43
+ return "" if params.nil? || params.empty?
44
+
45
+ lines = []
46
+ prefix = " " * indent
47
+
48
+ params.each do |key, value|
49
+ if value.is_a?(Hash)
50
+ lines << "#{prefix}#{h(key)}:"
51
+ lines << format_params(value, indent + 1)
52
+ elsif value.is_a?(Array)
53
+ lines << "#{prefix}#{h(key)}: #{h(value.inspect)}"
54
+ else
55
+ lines << "#{prefix}#{h(key)}: #{h(value)}"
56
+ end
57
+ end
58
+
59
+ lines.join("\n")
60
+ end
61
+
62
+ def truncate(text, length = 100)
63
+ return "" unless text
64
+
65
+ text.length > length ? "#{text[0...length]}..." : text
66
+ end
67
+
68
+ def time_ago(time)
69
+ return "unknown" unless time
70
+
71
+ seconds = Time.now.utc - time
72
+ case seconds
73
+ when 0..59 then "#{seconds.to_i}s ago"
74
+ when 60..3599 then "#{(seconds / 60).to_i}m ago"
75
+ else "#{(seconds / 3600).to_i}h ago"
76
+ end
77
+ end
78
+
79
+ # Cache compiled ERB template, reloading only if file changed
80
+ def cached_erb
81
+ current_mtime = File.mtime(@template_path) rescue nil
82
+
83
+ if @cached_erb.nil? || (current_mtime && current_mtime != @template_mtime)
84
+ template = File.read(@template_path)
85
+ @cached_erb = ERB.new(template, trim_mode: "-")
86
+ @template_mtime = current_mtime
87
+ end
88
+
89
+ @cached_erb
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "devtools/data/collector"
4
+ require_relative "devtools/middleware/asset_server"
5
+ require_relative "devtools/middleware/database_handler"
6
+ require_relative "devtools/middleware/error_page"
7
+ require_relative "devtools/middleware/debug_panel"
8
+ require_relative "devtools/renderers/error_page_renderer"
9
+ require_relative "devtools/renderers/debug_panel_renderer"
10
+
11
+ module BrainzLab
12
+ module DevTools
13
+ ASSETS_PATH = File.expand_path("devtools/assets", __dir__)
14
+
15
+ class << self
16
+ def enabled?
17
+ BrainzLab.configuration.devtools_enabled
18
+ end
19
+
20
+ def error_page_enabled?
21
+ enabled? && BrainzLab.configuration.devtools_error_page_enabled
22
+ end
23
+
24
+ def debug_panel_enabled?
25
+ enabled? && BrainzLab.configuration.devtools_debug_panel_enabled
26
+ end
27
+
28
+ def allowed_environment?
29
+ allowed = BrainzLab.configuration.devtools_allowed_environments
30
+ current = BrainzLab.configuration.environment
31
+ allowed.include?(current)
32
+ end
33
+
34
+ def allowed_ip?(request_ip)
35
+ # Skip IP checking in development - environment check is enough
36
+ return true if BrainzLab.configuration.environment == "development"
37
+
38
+ return true if BrainzLab.configuration.devtools_allowed_ips.empty?
39
+
40
+ allowed_ips = BrainzLab.configuration.devtools_allowed_ips
41
+ return true if allowed_ips.include?(request_ip)
42
+
43
+ # Check CIDR ranges
44
+ allowed_ips.any? do |ip|
45
+ if ip.include?("/")
46
+ ip_in_cidr?(request_ip, ip)
47
+ else
48
+ ip == request_ip
49
+ end
50
+ end
51
+ end
52
+
53
+ def asset_path
54
+ BrainzLab.configuration.devtools_asset_path
55
+ end
56
+
57
+ def panel_position
58
+ BrainzLab.configuration.devtools_panel_position
59
+ end
60
+
61
+ def expand_by_default?
62
+ BrainzLab.configuration.devtools_expand_by_default
63
+ end
64
+
65
+ private
66
+
67
+ def ip_in_cidr?(ip, cidr)
68
+ require "ipaddr"
69
+ IPAddr.new(cidr).include?(IPAddr.new(ip))
70
+ rescue IPAddr::InvalidAddressError
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end