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.
- checksums.yaml +4 -4
- data/README.md +8 -0
- data/lib/brainzlab/beacon/client.rb +209 -0
- data/lib/brainzlab/beacon/provisioner.rb +44 -0
- data/lib/brainzlab/beacon.rb +215 -0
- data/lib/brainzlab/configuration.rb +341 -3
- data/lib/brainzlab/cortex/cache.rb +59 -0
- data/lib/brainzlab/cortex/client.rb +141 -0
- data/lib/brainzlab/cortex/provisioner.rb +49 -0
- data/lib/brainzlab/cortex.rb +227 -0
- data/lib/brainzlab/dendrite/client.rb +232 -0
- data/lib/brainzlab/dendrite/provisioner.rb +44 -0
- data/lib/brainzlab/dendrite.rb +195 -0
- data/lib/brainzlab/devtools/assets/devtools.css +1106 -0
- data/lib/brainzlab/devtools/assets/devtools.js +322 -0
- data/lib/brainzlab/devtools/assets/logo.svg +6 -0
- data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +500 -0
- data/lib/brainzlab/devtools/assets/templates/error_page.html.erb +1086 -0
- data/lib/brainzlab/devtools/data/collector.rb +248 -0
- data/lib/brainzlab/devtools/middleware/asset_server.rb +63 -0
- data/lib/brainzlab/devtools/middleware/database_handler.rb +180 -0
- data/lib/brainzlab/devtools/middleware/debug_panel.rb +126 -0
- data/lib/brainzlab/devtools/middleware/error_page.rb +376 -0
- data/lib/brainzlab/devtools/renderers/debug_panel_renderer.rb +155 -0
- data/lib/brainzlab/devtools/renderers/error_page_renderer.rb +94 -0
- data/lib/brainzlab/devtools.rb +75 -0
- data/lib/brainzlab/flux/buffer.rb +96 -0
- data/lib/brainzlab/flux/client.rb +70 -0
- data/lib/brainzlab/flux/provisioner.rb +57 -0
- data/lib/brainzlab/flux.rb +174 -0
- data/lib/brainzlab/instrumentation/active_record.rb +18 -1
- data/lib/brainzlab/instrumentation/aws.rb +179 -0
- data/lib/brainzlab/instrumentation/dalli.rb +108 -0
- data/lib/brainzlab/instrumentation/excon.rb +152 -0
- data/lib/brainzlab/instrumentation/good_job.rb +102 -0
- data/lib/brainzlab/instrumentation/resque.rb +115 -0
- data/lib/brainzlab/instrumentation/solid_queue.rb +198 -0
- data/lib/brainzlab/instrumentation/stripe.rb +164 -0
- data/lib/brainzlab/instrumentation/typhoeus.rb +104 -0
- data/lib/brainzlab/instrumentation.rb +72 -0
- data/lib/brainzlab/nerve/client.rb +217 -0
- data/lib/brainzlab/nerve/provisioner.rb +44 -0
- data/lib/brainzlab/nerve.rb +219 -0
- data/lib/brainzlab/pulse/instrumentation.rb +35 -2
- data/lib/brainzlab/pulse/propagation.rb +1 -1
- data/lib/brainzlab/pulse/tracer.rb +1 -1
- data/lib/brainzlab/pulse.rb +1 -1
- data/lib/brainzlab/rails/log_subscriber.rb +1 -2
- data/lib/brainzlab/rails/railtie.rb +36 -3
- data/lib/brainzlab/recall/provisioner.rb +17 -0
- data/lib/brainzlab/recall.rb +6 -1
- data/lib/brainzlab/reflex.rb +2 -2
- data/lib/brainzlab/sentinel/client.rb +218 -0
- data/lib/brainzlab/sentinel/provisioner.rb +44 -0
- data/lib/brainzlab/sentinel.rb +165 -0
- data/lib/brainzlab/signal/client.rb +62 -0
- data/lib/brainzlab/signal/provisioner.rb +55 -0
- data/lib/brainzlab/signal.rb +136 -0
- data/lib/brainzlab/synapse/client.rb +290 -0
- data/lib/brainzlab/synapse/provisioner.rb +44 -0
- data/lib/brainzlab/synapse.rb +270 -0
- data/lib/brainzlab/utilities/circuit_breaker.rb +265 -0
- data/lib/brainzlab/utilities/health_check.rb +296 -0
- data/lib/brainzlab/utilities/log_formatter.rb +256 -0
- data/lib/brainzlab/utilities/rate_limiter.rb +230 -0
- data/lib/brainzlab/utilities.rb +17 -0
- data/lib/brainzlab/vault/cache.rb +80 -0
- data/lib/brainzlab/vault/client.rb +198 -0
- data/lib/brainzlab/vault/provisioner.rb +49 -0
- data/lib/brainzlab/vault.rb +268 -0
- data/lib/brainzlab/version.rb +1 -1
- data/lib/brainzlab/vision/client.rb +128 -0
- data/lib/brainzlab/vision/provisioner.rb +136 -0
- data/lib/brainzlab/vision.rb +157 -0
- data/lib/brainzlab.rb +101 -0
- metadata +60 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module DevTools
|
|
5
|
+
module Data
|
|
6
|
+
class Collector
|
|
7
|
+
THREAD_KEY = :brainzlab_devtools_data
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def start_request(env)
|
|
11
|
+
Thread.current[THREAD_KEY] = {
|
|
12
|
+
started_at: Time.now.utc,
|
|
13
|
+
sql_queries: [],
|
|
14
|
+
views: [],
|
|
15
|
+
logs: [],
|
|
16
|
+
memory_before: get_memory_usage,
|
|
17
|
+
env: env
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
subscribe_to_events
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def end_request
|
|
24
|
+
unsubscribe_from_events
|
|
25
|
+
data = Thread.current[THREAD_KEY]
|
|
26
|
+
Thread.current[THREAD_KEY] = nil
|
|
27
|
+
data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def active?
|
|
31
|
+
!Thread.current[THREAD_KEY].nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_request_data
|
|
35
|
+
data = Thread.current[THREAD_KEY] || {}
|
|
36
|
+
return {} if data.empty?
|
|
37
|
+
|
|
38
|
+
context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
|
|
39
|
+
duration_ms = data[:started_at] ? ((Time.now.utc - data[:started_at]) * 1000).round(2) : 0
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
timing: {
|
|
43
|
+
started_at: data[:started_at],
|
|
44
|
+
duration_ms: duration_ms
|
|
45
|
+
},
|
|
46
|
+
request: build_request_data(data, context),
|
|
47
|
+
controller: build_controller_data(context),
|
|
48
|
+
database: build_database_data(data[:sql_queries] || []),
|
|
49
|
+
views: build_views_data(data[:views] || []),
|
|
50
|
+
logs: data[:logs] || [],
|
|
51
|
+
memory: build_memory_data(data),
|
|
52
|
+
user: context&.user,
|
|
53
|
+
breadcrumbs: context&.breadcrumbs&.to_a || []
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_sql_query(name:, duration:, sql:, cached: false, source: nil)
|
|
58
|
+
data = Thread.current[THREAD_KEY]
|
|
59
|
+
return unless data
|
|
60
|
+
|
|
61
|
+
data[:sql_queries] << {
|
|
62
|
+
name: name,
|
|
63
|
+
duration: duration.round(2),
|
|
64
|
+
sql: sql,
|
|
65
|
+
sql_pattern: normalize_sql(sql),
|
|
66
|
+
cached: cached,
|
|
67
|
+
source: source,
|
|
68
|
+
timestamp: Time.now.utc
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_view(type:, template:, duration:, layout: nil)
|
|
73
|
+
data = Thread.current[THREAD_KEY]
|
|
74
|
+
return unless data
|
|
75
|
+
|
|
76
|
+
data[:views] << {
|
|
77
|
+
type: type,
|
|
78
|
+
template: template,
|
|
79
|
+
duration: duration.round(2),
|
|
80
|
+
layout: layout,
|
|
81
|
+
timestamp: Time.now.utc
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_log(level:, message:, log_data: nil)
|
|
86
|
+
request_data = Thread.current[THREAD_KEY]
|
|
87
|
+
return unless request_data
|
|
88
|
+
|
|
89
|
+
request_data[:logs] << {
|
|
90
|
+
level: level,
|
|
91
|
+
message: message,
|
|
92
|
+
data: log_data,
|
|
93
|
+
timestamp: Time.now.utc
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def build_request_data(data, context)
|
|
100
|
+
env = data[:env] || {}
|
|
101
|
+
request = env["action_dispatch.request"] || (defined?(ActionDispatch::Request) ? ActionDispatch::Request.new(env) : nil)
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
method: context&.request_method || env["REQUEST_METHOD"],
|
|
105
|
+
path: context&.request_path || env["PATH_INFO"],
|
|
106
|
+
url: context&.request_url || env["REQUEST_URI"],
|
|
107
|
+
params: context&.request_params || {},
|
|
108
|
+
headers: extract_headers(env),
|
|
109
|
+
request_id: context&.request_id || env["action_dispatch.request_id"]
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_controller_data(context)
|
|
114
|
+
{
|
|
115
|
+
name: context&.controller,
|
|
116
|
+
action: context&.action
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_database_data(queries)
|
|
121
|
+
{
|
|
122
|
+
queries: queries,
|
|
123
|
+
total_count: queries.length,
|
|
124
|
+
cached_count: queries.count { |q| q[:cached] },
|
|
125
|
+
total_duration_ms: queries.sum { |q| q[:duration] || 0 }.round(2),
|
|
126
|
+
n_plus_ones: detect_n_plus_ones(queries)
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_views_data(views)
|
|
131
|
+
{
|
|
132
|
+
templates: views,
|
|
133
|
+
total_count: views.length,
|
|
134
|
+
total_duration_ms: views.sum { |v| v[:duration] || 0 }.round(2)
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_memory_data(data)
|
|
139
|
+
current_memory = get_memory_usage
|
|
140
|
+
before_memory = data[:memory_before] || 0
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
before_mb: before_memory,
|
|
144
|
+
after_mb: current_memory,
|
|
145
|
+
delta_mb: (current_memory - before_memory).round(2)
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def extract_headers(env)
|
|
150
|
+
headers = {}
|
|
151
|
+
env.each do |key, value|
|
|
152
|
+
if key.start_with?("HTTP_")
|
|
153
|
+
header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
|
|
154
|
+
headers[header_name] = value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
headers
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def subscribe_to_events
|
|
161
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
162
|
+
|
|
163
|
+
@sql_subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
164
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
165
|
+
next if event.payload[:name] == "SCHEMA"
|
|
166
|
+
next if event.payload[:sql]&.start_with?("PRAGMA")
|
|
167
|
+
|
|
168
|
+
add_sql_query(
|
|
169
|
+
name: event.payload[:name],
|
|
170
|
+
duration: event.duration,
|
|
171
|
+
sql: event.payload[:sql],
|
|
172
|
+
cached: event.payload[:cached] || event.payload[:name] == "CACHE",
|
|
173
|
+
source: extract_source(caller)
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@view_subscriber = ActiveSupport::Notifications.subscribe(/render_.+\.action_view/) do |*args|
|
|
178
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
179
|
+
type = event.name.include?("partial") ? :partial : :template
|
|
180
|
+
|
|
181
|
+
add_view(
|
|
182
|
+
type: type,
|
|
183
|
+
template: event.payload[:identifier],
|
|
184
|
+
duration: event.duration,
|
|
185
|
+
layout: event.payload[:layout]
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def unsubscribe_from_events
|
|
191
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
192
|
+
|
|
193
|
+
ActiveSupport::Notifications.unsubscribe(@sql_subscriber) if @sql_subscriber
|
|
194
|
+
ActiveSupport::Notifications.unsubscribe(@view_subscriber) if @view_subscriber
|
|
195
|
+
@sql_subscriber = nil
|
|
196
|
+
@view_subscriber = nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def detect_n_plus_ones(queries)
|
|
200
|
+
non_cached = queries.reject { |q| q[:cached] }
|
|
201
|
+
pattern_groups = non_cached.group_by { |q| q[:sql_pattern] }
|
|
202
|
+
|
|
203
|
+
pattern_groups.select { |_, qs| qs.size >= 3 }.map do |pattern, matching|
|
|
204
|
+
{
|
|
205
|
+
pattern: pattern,
|
|
206
|
+
count: matching.size,
|
|
207
|
+
total_duration_ms: matching.sum { |q| q[:duration] || 0 }.round(2),
|
|
208
|
+
sample_query: matching.first[:sql],
|
|
209
|
+
source: matching.first[:source]
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalize_sql(sql)
|
|
215
|
+
return nil unless sql
|
|
216
|
+
|
|
217
|
+
sql.gsub(/\b\d+\b/, "?")
|
|
218
|
+
.gsub(/'[^']*'/, "?")
|
|
219
|
+
.gsub(/\$\d+/, "?")
|
|
220
|
+
.gsub(%r{/\*.*?\*/}, "")
|
|
221
|
+
.gsub(/\s+/, " ")
|
|
222
|
+
.strip
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def get_memory_usage
|
|
226
|
+
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
|
|
227
|
+
rescue StandardError
|
|
228
|
+
0
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def extract_source(backtrace)
|
|
232
|
+
backtrace.each do |line|
|
|
233
|
+
next if line.include?("/brainzlab")
|
|
234
|
+
next if line.include?("/gems/")
|
|
235
|
+
next if line.include?("/ruby/")
|
|
236
|
+
|
|
237
|
+
if line.include?("/app/")
|
|
238
|
+
match = line.match(%r{(app/[^:]+:\d+)})
|
|
239
|
+
return match[1] if match
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module DevTools
|
|
5
|
+
module Middleware
|
|
6
|
+
class AssetServer
|
|
7
|
+
MIME_TYPES = {
|
|
8
|
+
".css" => "text/css; charset=utf-8",
|
|
9
|
+
".js" => "application/javascript; charset=utf-8",
|
|
10
|
+
".svg" => "image/svg+xml",
|
|
11
|
+
".png" => "image/png",
|
|
12
|
+
".woff2" => "font/woff2"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = app
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
return @app.call(env) unless DevTools.enabled?
|
|
21
|
+
|
|
22
|
+
path = env["PATH_INFO"]
|
|
23
|
+
asset_prefix = DevTools.asset_path
|
|
24
|
+
|
|
25
|
+
if path.start_with?("#{asset_prefix}/")
|
|
26
|
+
serve_asset(path.sub("#{asset_prefix}/", ""))
|
|
27
|
+
else
|
|
28
|
+
@app.call(env)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def serve_asset(relative_path)
|
|
35
|
+
# Prevent directory traversal
|
|
36
|
+
return not_found if relative_path.include?("..")
|
|
37
|
+
|
|
38
|
+
file_path = File.join(DevTools::ASSETS_PATH, relative_path)
|
|
39
|
+
return not_found unless File.exist?(file_path)
|
|
40
|
+
|
|
41
|
+
ext = File.extname(relative_path)
|
|
42
|
+
content_type = MIME_TYPES[ext] || "application/octet-stream"
|
|
43
|
+
content = File.read(file_path)
|
|
44
|
+
|
|
45
|
+
[
|
|
46
|
+
200,
|
|
47
|
+
{
|
|
48
|
+
"Content-Type" => content_type,
|
|
49
|
+
"Content-Length" => content.bytesize.to_s,
|
|
50
|
+
"Cache-Control" => "public, max-age=31536000",
|
|
51
|
+
"X-Content-Type-Options" => "nosniff"
|
|
52
|
+
},
|
|
53
|
+
[content]
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def not_found
|
|
58
|
+
[404, { "Content-Type" => "text/plain" }, ["Not Found"]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module DevTools
|
|
5
|
+
module Middleware
|
|
6
|
+
class DatabaseHandler
|
|
7
|
+
ENDPOINT = "/_brainzlab/devtools/database"
|
|
8
|
+
|
|
9
|
+
def initialize(app)
|
|
10
|
+
@app = app
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
return @app.call(env) unless should_handle?(env)
|
|
15
|
+
|
|
16
|
+
handle_database_request(env)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def should_handle?(env)
|
|
22
|
+
return false unless DevTools.enabled?
|
|
23
|
+
return false unless DevTools.allowed_environment?
|
|
24
|
+
return false unless DevTools.allowed_ip?(extract_ip(env))
|
|
25
|
+
return false unless env["PATH_INFO"] == ENDPOINT
|
|
26
|
+
return false unless env["REQUEST_METHOD"] == "POST"
|
|
27
|
+
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def extract_ip(env)
|
|
32
|
+
forwarded = env["HTTP_X_FORWARDED_FOR"]
|
|
33
|
+
return forwarded.split(",").first.strip if forwarded
|
|
34
|
+
|
|
35
|
+
env["REMOTE_ADDR"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_database_request(env)
|
|
39
|
+
begin
|
|
40
|
+
body = env["rack.input"].read
|
|
41
|
+
env["rack.input"].rewind
|
|
42
|
+
params = JSON.parse(body)
|
|
43
|
+
action = params["action"]
|
|
44
|
+
|
|
45
|
+
result = case action
|
|
46
|
+
when "migrate"
|
|
47
|
+
run_migrations
|
|
48
|
+
when "status"
|
|
49
|
+
migration_status
|
|
50
|
+
when "create"
|
|
51
|
+
create_database
|
|
52
|
+
when "rollback"
|
|
53
|
+
rollback_migration
|
|
54
|
+
else
|
|
55
|
+
{ success: false, output: "Unknown action: #{action}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
json_response(result)
|
|
59
|
+
rescue => e
|
|
60
|
+
json_response({ success: false, output: "Error: #{e.message}\n\n#{e.backtrace&.first(10)&.join("\n")}" })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run_migrations
|
|
65
|
+
return not_available("Rails") unless defined?(Rails)
|
|
66
|
+
|
|
67
|
+
output = capture_output do
|
|
68
|
+
ActiveRecord::MigrationContext.new(
|
|
69
|
+
Rails.root.join("db/migrate"),
|
|
70
|
+
ActiveRecord::SchemaMigration
|
|
71
|
+
).migrate
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{ success: true, output: output.presence || "All migrations completed successfully!" }
|
|
75
|
+
rescue => e
|
|
76
|
+
{ success: false, output: "Migration failed:\n#{e.message}\n\n#{e.backtrace&.first(10)&.join("\n")}" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def migration_status
|
|
80
|
+
return not_available("Rails") unless defined?(Rails)
|
|
81
|
+
|
|
82
|
+
output = StringIO.new
|
|
83
|
+
|
|
84
|
+
context = ActiveRecord::MigrationContext.new(
|
|
85
|
+
Rails.root.join("db/migrate"),
|
|
86
|
+
ActiveRecord::SchemaMigration
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
migrated = context.get_all_versions.to_set
|
|
90
|
+
migrations = context.migrations
|
|
91
|
+
|
|
92
|
+
output.puts "database: #{ActiveRecord::Base.connection_db_config.database}"
|
|
93
|
+
output.puts ""
|
|
94
|
+
output.puts " Status Migration ID Migration Name"
|
|
95
|
+
output.puts "-" * 60
|
|
96
|
+
|
|
97
|
+
migrations.each do |migration|
|
|
98
|
+
status = migrated.include?(migration.version) ? " up" : " down"
|
|
99
|
+
output.puts " #{status} #{migration.version} #{migration.name}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
pending = migrations.reject { |m| migrated.include?(m.version) }
|
|
103
|
+
if pending.any?
|
|
104
|
+
output.puts ""
|
|
105
|
+
output.puts "#{pending.count} pending migration(s)"
|
|
106
|
+
else
|
|
107
|
+
output.puts ""
|
|
108
|
+
output.puts "All migrations are up to date!"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
{ success: true, output: output.string }
|
|
112
|
+
rescue => e
|
|
113
|
+
{ success: false, output: "Failed to check status:\n#{e.message}" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def create_database
|
|
117
|
+
return not_available("Rails") unless defined?(Rails)
|
|
118
|
+
|
|
119
|
+
output = capture_output do
|
|
120
|
+
ActiveRecord::Tasks::DatabaseTasks.create_current
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{ success: true, output: output.presence || "Database created successfully!" }
|
|
124
|
+
rescue ActiveRecord::DatabaseAlreadyExists
|
|
125
|
+
{ success: true, output: "Database already exists." }
|
|
126
|
+
rescue => e
|
|
127
|
+
{ success: false, output: "Failed to create database:\n#{e.message}" }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def rollback_migration
|
|
131
|
+
return not_available("Rails") unless defined?(Rails)
|
|
132
|
+
|
|
133
|
+
output = capture_output do
|
|
134
|
+
ActiveRecord::MigrationContext.new(
|
|
135
|
+
Rails.root.join("db/migrate"),
|
|
136
|
+
ActiveRecord::SchemaMigration
|
|
137
|
+
).rollback
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
{ success: true, output: output.presence || "Rollback completed!" }
|
|
141
|
+
rescue => e
|
|
142
|
+
{ success: false, output: "Rollback failed:\n#{e.message}" }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def capture_output
|
|
146
|
+
original_stdout = $stdout
|
|
147
|
+
original_stderr = $stderr
|
|
148
|
+
captured = StringIO.new
|
|
149
|
+
$stdout = captured
|
|
150
|
+
$stderr = captured
|
|
151
|
+
|
|
152
|
+
yield
|
|
153
|
+
|
|
154
|
+
captured.string
|
|
155
|
+
ensure
|
|
156
|
+
$stdout = original_stdout
|
|
157
|
+
$stderr = original_stderr
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def not_available(framework)
|
|
161
|
+
{ success: false, output: "#{framework} is not available." }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def json_response(data)
|
|
165
|
+
body = JSON.generate(data)
|
|
166
|
+
[
|
|
167
|
+
200,
|
|
168
|
+
{
|
|
169
|
+
"Content-Type" => "application/json; charset=utf-8",
|
|
170
|
+
"Content-Length" => body.bytesize.to_s,
|
|
171
|
+
"Cache-Control" => "no-store",
|
|
172
|
+
"X-Content-Type-Options" => "nosniff"
|
|
173
|
+
},
|
|
174
|
+
[body]
|
|
175
|
+
]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module DevTools
|
|
5
|
+
module Middleware
|
|
6
|
+
class DebugPanel
|
|
7
|
+
HTML_CONTENT_TYPE = "text/html"
|
|
8
|
+
|
|
9
|
+
def initialize(app)
|
|
10
|
+
@app = app
|
|
11
|
+
@renderer = Renderers::DebugPanelRenderer.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
return @app.call(env) unless should_inject?(env)
|
|
16
|
+
|
|
17
|
+
# Start collecting data
|
|
18
|
+
Data::Collector.start_request(env)
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
status, headers, body = @app.call(env)
|
|
22
|
+
|
|
23
|
+
# Only inject into HTML responses
|
|
24
|
+
if injectable_response?(status, headers)
|
|
25
|
+
body = inject_panel(body, env, status, headers)
|
|
26
|
+
headers = update_content_length(headers, body)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
[status, headers, body]
|
|
30
|
+
ensure
|
|
31
|
+
Data::Collector.end_request
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def should_inject?(env)
|
|
38
|
+
return false unless DevTools.debug_panel_enabled?
|
|
39
|
+
return false unless DevTools.allowed_environment?
|
|
40
|
+
return false unless DevTools.allowed_ip?(extract_ip(env))
|
|
41
|
+
return false if asset_request?(env["PATH_INFO"])
|
|
42
|
+
return false if devtools_asset_request?(env["PATH_INFO"])
|
|
43
|
+
return false if turbo_stream_request?(env)
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_ip(env)
|
|
49
|
+
forwarded = env["HTTP_X_FORWARDED_FOR"]
|
|
50
|
+
return forwarded.split(",").first.strip if forwarded
|
|
51
|
+
|
|
52
|
+
env["REMOTE_ADDR"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def injectable_response?(status, headers)
|
|
56
|
+
return false unless status == 200
|
|
57
|
+
|
|
58
|
+
content_type = headers["Content-Type"]
|
|
59
|
+
return false unless content_type
|
|
60
|
+
|
|
61
|
+
content_type.include?(HTML_CONTENT_TYPE)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def asset_request?(path)
|
|
65
|
+
return true if path.nil?
|
|
66
|
+
|
|
67
|
+
asset_paths = %w[/assets /packs /vite]
|
|
68
|
+
asset_extensions = %w[.js .css .map .png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot]
|
|
69
|
+
|
|
70
|
+
asset_paths.any? { |p| path.start_with?(p) } ||
|
|
71
|
+
asset_extensions.any? { |ext| path.end_with?(ext) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def devtools_asset_request?(path)
|
|
75
|
+
return false if path.nil?
|
|
76
|
+
|
|
77
|
+
path.start_with?(DevTools.asset_path)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def turbo_stream_request?(env)
|
|
81
|
+
accept = env["HTTP_ACCEPT"] || ""
|
|
82
|
+
accept.include?("text/vnd.turbo-stream.html")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inject_panel(body, env, status, headers)
|
|
86
|
+
# Collect all response body parts
|
|
87
|
+
full_body = collect_body(body)
|
|
88
|
+
|
|
89
|
+
# Get collected data
|
|
90
|
+
data = Data::Collector.get_request_data
|
|
91
|
+
data[:response] = {
|
|
92
|
+
status: status,
|
|
93
|
+
headers: headers.to_h,
|
|
94
|
+
content_type: headers["Content-Type"]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Render panel HTML
|
|
98
|
+
panel_html = @renderer.render(data)
|
|
99
|
+
|
|
100
|
+
# Inject before </body>
|
|
101
|
+
if full_body.include?("</body>")
|
|
102
|
+
full_body = full_body.sub("</body>", "#{panel_html}</body>")
|
|
103
|
+
else
|
|
104
|
+
# If no </body> tag, append at the end
|
|
105
|
+
full_body = "#{full_body}#{panel_html}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
[full_body]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def collect_body(body)
|
|
112
|
+
full_body = +""
|
|
113
|
+
body.each { |part| full_body << part }
|
|
114
|
+
body.close if body.respond_to?(:close)
|
|
115
|
+
full_body
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def update_content_length(headers, body)
|
|
119
|
+
headers = headers.to_h.dup
|
|
120
|
+
headers["Content-Length"] = body.sum(&:bytesize).to_s
|
|
121
|
+
headers
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|