devformance 0.1.0
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 +7 -0
- data/README.md +205 -0
- data/app/assets/builds/tailwind.css +2 -0
- data/app/assets/images/icon.png +0 -0
- data/app/assets/images/icon.svg +68 -0
- data/app/assets/stylesheets/devmetrics/dashboard.css +476 -0
- data/app/assets/stylesheets/devmetrics_live/application.css +10 -0
- data/app/assets/tailwind/application.css +1 -0
- data/app/channels/application_cable/channel.rb +4 -0
- data/app/channels/application_cable/connection.rb +4 -0
- data/app/channels/devformance/metrics_channel.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/controllers/devformance/application_controller.rb +19 -0
- data/app/controllers/devformance/icons_controller.rb +21 -0
- data/app/controllers/devformance/metrics_controller.rb +41 -0
- data/app/controllers/devformance/playground_controller.rb +89 -0
- data/app/helpers/application_helper.rb +9 -0
- data/app/helpers/metrics_helper.rb +2 -0
- data/app/helpers/playground_helper.rb +2 -0
- data/app/javascript/devformance/channels/consumer.js +2 -0
- data/app/javascript/devformance/channels/index.js +1 -0
- data/app/javascript/devformance/controllers/application.js +9 -0
- data/app/javascript/devformance/controllers/hello_controller.js +7 -0
- data/app/javascript/devformance/controllers/index.js +14 -0
- data/app/javascript/devformance/controllers/metrics_controller.js +364 -0
- data/app/javascript/devformance/controllers/playground_controller.js +33 -0
- data/app/javascript/devmetrics.js +4 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/devformance/file_runner_job.rb +318 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/devformance/file_result.rb +14 -0
- data/app/models/devformance/run.rb +19 -0
- data/app/models/devformance/slow_query.rb +5 -0
- data/app/views/devformance/metrics/index.html.erb +79 -0
- data/app/views/devformance/playground/run.html.erb +63 -0
- data/app/views/layouts/devformance/application.html.erb +856 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/metrics/index.html.erb +334 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/config/BUSINESS_LOGIC_PLAN.md +1244 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +4 -0
- data/config/cable.yml +17 -0
- data/config/cache.yml +16 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +98 -0
- data/config/deploy.yml +116 -0
- data/config/engine_routes.rb +13 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +84 -0
- data/config/environments/production.rb +90 -0
- data/config/environments/test.rb +59 -0
- data/config/importmap.rb +11 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/content_security_policy.rb +25 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/locales/en.yml +31 -0
- data/config/master.key +1 -0
- data/config/puma.rb +41 -0
- data/config/queue.yml +22 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +20 -0
- data/config/storage.yml +34 -0
- data/db/migrate/20260317144616_create_slow_queries.rb +13 -0
- data/db/migrate/20260317175630_create_performance_runs.rb +14 -0
- data/db/migrate/20260317195043_add_run_id_to_slow_queries.rb +10 -0
- data/db/migrate/20260319000001_create_devformance_runs.rb +20 -0
- data/db/migrate/20260319000002_create_devformance_file_results.rb +29 -0
- data/db/migrate/20260319000003_add_columns_to_slow_queries.rb +7 -0
- data/lib/devformance/bullet_log_parser.rb +47 -0
- data/lib/devformance/compatibility.rb +12 -0
- data/lib/devformance/coverage_setup.rb +33 -0
- data/lib/devformance/engine.rb +80 -0
- data/lib/devformance/log_writer.rb +29 -0
- data/lib/devformance/run_orchestrator.rb +58 -0
- data/lib/devformance/sql_instrumentor.rb +29 -0
- data/lib/devformance/test_framework/base.rb +43 -0
- data/lib/devformance/test_framework/coverage_helper.rb +76 -0
- data/lib/devformance/test_framework/detector.rb +26 -0
- data/lib/devformance/test_framework/minitest.rb +71 -0
- data/lib/devformance/test_framework/registry.rb +24 -0
- data/lib/devformance/test_framework/rspec.rb +60 -0
- data/lib/devformance/test_helper.rb +42 -0
- data/lib/devformance/version.rb +3 -0
- data/lib/devformance.rb +196 -0
- data/lib/generators/devformance/install/install_generator.rb +73 -0
- data/lib/generators/devformance/install/templates/add_columns_to_slow_queries.rb.erb +7 -0
- data/lib/generators/devformance/install/templates/add_run_id_to_slow_queries.rb.erb +10 -0
- data/lib/generators/devformance/install/templates/create_devformance_file_results.rb.erb +29 -0
- data/lib/generators/devformance/install/templates/create_devformance_runs.rb.erb +20 -0
- data/lib/generators/devformance/install/templates/create_performance_runs.rb.erb +14 -0
- data/lib/generators/devformance/install/templates/create_slow_queries.rb.erb +13 -0
- data/lib/generators/devformance/install/templates/initializer.rb +23 -0
- data/lib/tasks/devformance.rake +45 -0
- data/spec/fixtures/devformance/devformance_run.rb +27 -0
- data/spec/fixtures/devformance/file_result.rb +34 -0
- data/spec/fixtures/devformance/slow_query.rb +11 -0
- data/spec/lib/devmetrics/log_writer_spec.rb +81 -0
- data/spec/lib/devmetrics/run_orchestrator_spec.rb +102 -0
- data/spec/lib/devmetrics/sql_instrumentor_spec.rb +115 -0
- data/spec/models/devmetrics/file_result_spec.rb +87 -0
- data/spec/models/devmetrics/run_spec.rb +66 -0
- data/spec/models/query_log_spec.rb +21 -0
- data/spec/rails_helper.rb +20 -0
- data/spec/requests/devmetrics/metrics_controller_spec.rb +149 -0
- data/spec/requests/devmetrics_pages_spec.rb +12 -0
- data/spec/requests/performance_spec.rb +17 -0
- data/spec/requests/slow_perf_spec.rb +9 -0
- data/spec/spec_helper.rb +114 -0
- data/spec/support/devmetrics_formatter.rb +106 -0
- data/spec/support/devmetrics_metrics.rb +37 -0
- data/spec/support/factory_bot.rb +3 -0
- metadata +200 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class FileRunnerJob < ActiveJob::Base
|
|
3
|
+
queue_as :devformance
|
|
4
|
+
|
|
5
|
+
SLOW_THRESHOLD_MS = -> { Devformance.configuration.slow_query_threshold_ms }
|
|
6
|
+
|
|
7
|
+
def perform(run_id:, file_path:, file_key:, framework: nil)
|
|
8
|
+
ENV["DEVMETRICS_SKIP_DB_SETUP"] = "1"
|
|
9
|
+
@framework = build_framework(framework)
|
|
10
|
+
result = ::Devformance::FileResult.find_by!(run_id: run_id, file_key: file_key)
|
|
11
|
+
result.update!(status: :running)
|
|
12
|
+
|
|
13
|
+
log = ::Devformance::LogWriter.open(run_id, file_key)
|
|
14
|
+
stream = "devformance:file:#{file_key}:#{run_id}"
|
|
15
|
+
|
|
16
|
+
broadcast(stream, type: "file_started", file_key: file_key, framework: @framework.name)
|
|
17
|
+
|
|
18
|
+
::Devformance::SqlInstrumentor.around_run do
|
|
19
|
+
run_tests(file_path, stream, log, result)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
flush_sql_results(stream, log, result)
|
|
23
|
+
write_coverage(stream, log, result, file_path)
|
|
24
|
+
finalize(stream, log, result, run_id)
|
|
25
|
+
rescue => e
|
|
26
|
+
broadcast(stream, type: "file_error", message: e.message)
|
|
27
|
+
result&.update!(status: :failed)
|
|
28
|
+
ensure
|
|
29
|
+
log&.close
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_framework(framework_name)
|
|
35
|
+
return Devformance::TestFramework::Detector.detect(Rails.root) unless framework_name
|
|
36
|
+
|
|
37
|
+
framework_class = Devformance::TestFramework::Registry.for_name(framework_name)
|
|
38
|
+
return Devformance::TestFramework::RSpec.new(Rails.root) unless framework_class
|
|
39
|
+
|
|
40
|
+
framework_class.new(Rails.root)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run_tests(file_path, stream, log, result)
|
|
44
|
+
env = {
|
|
45
|
+
"RAILS_ENV" => "test",
|
|
46
|
+
"DEVMETRICS_SKIP_DB_SETUP" => "1",
|
|
47
|
+
"BUNDLE_GEMFILE" => Rails.root.join("Gemfile").to_s
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
coverage_enabled = devformance_coverage_enabled?
|
|
51
|
+
if coverage_enabled
|
|
52
|
+
env["COVERAGE"] = "true"
|
|
53
|
+
env["SIMPLECOV"] = "true"
|
|
54
|
+
env["DEVMETRICS_INCLUDE_TESTS"] = "true"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
cmd = @framework.run_command(file_path, coverage: coverage_enabled)
|
|
58
|
+
|
|
59
|
+
bullet_log_path = Rails.root.join("log", "bullet.log")
|
|
60
|
+
File.truncate(bullet_log_path, 0) if File.exist?(bullet_log_path)
|
|
61
|
+
|
|
62
|
+
start = Time.current
|
|
63
|
+
stdout_all = ""
|
|
64
|
+
|
|
65
|
+
IO.popen(env, cmd, err: [ :child, :out ], chdir: Rails.root.to_s) do |io|
|
|
66
|
+
io.each_line do |raw_line|
|
|
67
|
+
line = raw_line.chomp
|
|
68
|
+
stdout_all += line + "\n"
|
|
69
|
+
log.write(line)
|
|
70
|
+
|
|
71
|
+
event_type = @framework.classify_line(line)
|
|
72
|
+
broadcast(stream, type: "test_output", line: line, event_type: event_type)
|
|
73
|
+
|
|
74
|
+
if event_type == "coverage" && @framework.respond_to?(:extract_coverage)
|
|
75
|
+
pct = @framework.extract_coverage(line)
|
|
76
|
+
if pct
|
|
77
|
+
broadcast(stream, type: "coverage_update", pct: pct)
|
|
78
|
+
result.update!(coverage: pct)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
exit_status = $?
|
|
85
|
+
|
|
86
|
+
stream_bullet_log(bullet_log_path, stream, log, result)
|
|
87
|
+
stream_coverage_summary(file_path, stream, log)
|
|
88
|
+
|
|
89
|
+
summary = @framework.parse_summary(stdout_all)
|
|
90
|
+
status = exit_status.success? && summary[:failures] == 0 ? :passed : :failed
|
|
91
|
+
|
|
92
|
+
result.update!(
|
|
93
|
+
status: status,
|
|
94
|
+
passed_tests: summary[:passes],
|
|
95
|
+
failed_tests: summary[:failures],
|
|
96
|
+
total_tests: summary[:examples],
|
|
97
|
+
duration_ms: ((Time.current - start) * 1000).round
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stream_bullet_log(bullet_log_path, stream, log, result)
|
|
102
|
+
return unless File.exist?(bullet_log_path)
|
|
103
|
+
|
|
104
|
+
content = File.read(bullet_log_path).strip
|
|
105
|
+
return if content.blank?
|
|
106
|
+
|
|
107
|
+
warnings = ::Devformance::BulletLogParser.parse(content)
|
|
108
|
+
return if warnings.empty?
|
|
109
|
+
|
|
110
|
+
emit(stream, log, "")
|
|
111
|
+
emit(stream, log, "── Bullet Warnings (#{warnings.size}) ──")
|
|
112
|
+
|
|
113
|
+
warnings.each do |w|
|
|
114
|
+
prefix = w.type == :add_eager_load ? "[N+1]" : "[UNUSED EAGER]"
|
|
115
|
+
emit(stream, log, " #{prefix} #{w.model_class} => #{w.associations} — #{w.fix_suggestion}")
|
|
116
|
+
if w.call_stack.first
|
|
117
|
+
source = w.call_stack.find { |l| !l.include?("/gems/") && !l.include?("/ruby/") } || w.call_stack.first
|
|
118
|
+
emit(stream, log, " Source: #{source}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
::Devformance::SlowQuery.create!(
|
|
122
|
+
run_id: result.run_id,
|
|
123
|
+
file_key: result.file_key,
|
|
124
|
+
model_class: w.model_class,
|
|
125
|
+
fix_suggestion: w.fix_suggestion,
|
|
126
|
+
line_number: w.line_number
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if w.type == :add_eager_load
|
|
130
|
+
broadcast(stream, type: "n1_detected", message: "#{w.model_class} => #{w.associations} — #{w.fix_suggestion}")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
result.update!(n1_count: warnings.count { |w| w.type == :add_eager_load })
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def stream_coverage_summary(file_path, stream, log)
|
|
138
|
+
resultset_path = Rails.root.join("coverage", ".resultset.json")
|
|
139
|
+
return unless File.exist?(resultset_path)
|
|
140
|
+
|
|
141
|
+
rs = JSON.parse(File.read(resultset_path)) rescue {}
|
|
142
|
+
pct = extract_coverage_pct(rs, file_path)
|
|
143
|
+
return unless pct
|
|
144
|
+
|
|
145
|
+
emit(stream, log, "")
|
|
146
|
+
emit(stream, log, "── Coverage: #{pct.round(1)}% ──")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def emit(stream, log, line)
|
|
150
|
+
log.write(line)
|
|
151
|
+
broadcast(stream, type: "test_output", line: line, event_type: "info")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_rspec_summary(output)
|
|
155
|
+
if output =~ /(\d+)\s+examples?,\s+(\d+)\s+failures?(?:,\s+(\d+)\s+pending)?/m
|
|
156
|
+
examples = $1.to_i
|
|
157
|
+
failures = $2.to_i
|
|
158
|
+
pending = $3.to_i rescue 0
|
|
159
|
+
{ examples: examples, failures: failures, pending: pending, passes: examples - failures - pending }
|
|
160
|
+
else
|
|
161
|
+
{ examples: 0, failures: 0, pending: 0, passes: 0 }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def flush_sql_results(stream, log, result)
|
|
166
|
+
queries = ::Devformance::SqlInstrumentor.queries
|
|
167
|
+
slow = queries.select { |q| q[:ms] >= SLOW_THRESHOLD_MS.call }
|
|
168
|
+
n1_groups = detect_n1_patterns(queries)
|
|
169
|
+
|
|
170
|
+
slow.each do |q|
|
|
171
|
+
entry = { sql: q[:sql].truncate(200), ms: q[:ms] }
|
|
172
|
+
log.write(" [SLOW #{q[:ms]}ms] #{q[:sql].truncate(120)}")
|
|
173
|
+
broadcast(stream, type: "slow_query", query: entry)
|
|
174
|
+
|
|
175
|
+
::Devformance::SlowQuery.create!(
|
|
176
|
+
run_id: result.run_id,
|
|
177
|
+
file_key: result.file_key,
|
|
178
|
+
sql: q[:sql],
|
|
179
|
+
duration_ms: q[:ms]
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
n1_groups.each do |pattern, count|
|
|
184
|
+
msg = "N+1 detected: #{pattern} (#{count}x) — add includes(:#{n1_association(pattern)})"
|
|
185
|
+
log.write(" [N+1] #{msg}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
result.update!(
|
|
189
|
+
slow_query_count: slow.size,
|
|
190
|
+
n1_count: result.n1_count + n1_groups.size
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def write_coverage(stream, log, result, file_path)
|
|
195
|
+
resultset_path = Rails.root.join("coverage", ".resultset.json")
|
|
196
|
+
return unless File.exist?(resultset_path)
|
|
197
|
+
|
|
198
|
+
rs = JSON.parse(File.read(resultset_path)) rescue {}
|
|
199
|
+
pct = extract_coverage_pct(rs, file_path)
|
|
200
|
+
return unless pct
|
|
201
|
+
|
|
202
|
+
broadcast(stream, type: "coverage_update", pct: pct.round(1))
|
|
203
|
+
result.update!(coverage: pct.round(1))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def finalize(stream, log, result, run_id)
|
|
207
|
+
log.write("")
|
|
208
|
+
log.write("=" * 60)
|
|
209
|
+
log.write("Result: #{result.status.upcase} | " \
|
|
210
|
+
"#{result.total_tests} tests, #{result.failed_tests} failures | " \
|
|
211
|
+
"#{result.slow_query_count} slow queries | #{result.n1_count} N+1 issues")
|
|
212
|
+
|
|
213
|
+
result.update!(log_path: log.path)
|
|
214
|
+
|
|
215
|
+
broadcast(stream, type: "file_complete",
|
|
216
|
+
status: result.status,
|
|
217
|
+
passed: result.passed_tests,
|
|
218
|
+
failed: result.failed_tests,
|
|
219
|
+
slow_count: result.slow_query_count,
|
|
220
|
+
n1_count: result.n1_count,
|
|
221
|
+
coverage: result.coverage,
|
|
222
|
+
duration_ms: result.duration_ms,
|
|
223
|
+
log_path: result.log_path
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
run = ::Devformance::Run.find_by(run_id: run_id)
|
|
227
|
+
if run
|
|
228
|
+
run.with_lock do
|
|
229
|
+
run.increment!(:completed_files)
|
|
230
|
+
if run.completed_files >= run.total_files
|
|
231
|
+
run.update!(status: :completed, finished_at: Time.current)
|
|
232
|
+
ActionCable.server.broadcast(
|
|
233
|
+
"devformance:run:#{run_id}",
|
|
234
|
+
{ type: "run_complete", run_id: run_id, total_files: run.total_files }
|
|
235
|
+
)
|
|
236
|
+
write_run_summary(run)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def broadcast(stream, payload)
|
|
243
|
+
ActionCable.server.broadcast(stream, payload)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def devformance_coverage_enabled?
|
|
247
|
+
if Devformance.configuration.respond_to?(:coverage?)
|
|
248
|
+
Devformance.configuration.coverage?
|
|
249
|
+
else
|
|
250
|
+
Devformance.configuration.coverage_enabled != false
|
|
251
|
+
end
|
|
252
|
+
rescue
|
|
253
|
+
false
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def detect_n1_patterns(queries)
|
|
257
|
+
pattern_counts = Hash.new(0)
|
|
258
|
+
queries.each do |q|
|
|
259
|
+
normalized = q[:sql].gsub(/\d+/, "?").gsub(/'[^']*'/, "?").strip
|
|
260
|
+
pattern_counts[normalized] += 1
|
|
261
|
+
end
|
|
262
|
+
pattern_counts.select { |_, count| count >= 3 }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def n1_association(pattern)
|
|
266
|
+
pattern.match(/FROM "?(\w+)"?/i)&.captures&.first&.singularize || "association"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def extract_coverage_pct(resultset, file_path)
|
|
270
|
+
return nil unless resultset.is_a?(Hash)
|
|
271
|
+
|
|
272
|
+
all_lines = []
|
|
273
|
+
resultset.each do |_framework_key, framework_data|
|
|
274
|
+
next unless framework_data.is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
coverage_data = framework_data["coverage"]
|
|
277
|
+
next unless coverage_data.is_a?(Hash)
|
|
278
|
+
|
|
279
|
+
coverage_data.each do |_path, file_data|
|
|
280
|
+
lines = file_data.is_a?(Hash) ? file_data["lines"] : file_data
|
|
281
|
+
all_lines.concat(lines.compact) if lines.is_a?(Array)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
return nil if all_lines.empty?
|
|
286
|
+
covered = all_lines.count { |v| v.to_i > 0 }
|
|
287
|
+
(covered.to_f / all_lines.size * 100).round(1)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def json_output_path(run_id, file_key)
|
|
291
|
+
dir = Rails.root.join("log", "devformance", "runs", run_id.to_s)
|
|
292
|
+
FileUtils.mkdir_p(dir)
|
|
293
|
+
dir.join("#{file_key}.json").to_s
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def write_run_summary(run)
|
|
297
|
+
results = run.file_results
|
|
298
|
+
dir = Rails.root.join("log", "devformance", "runs", run.run_id.to_s)
|
|
299
|
+
summary = {
|
|
300
|
+
run_id: run.run_id,
|
|
301
|
+
started_at: run.started_at.iso8601,
|
|
302
|
+
finished_at: run.finished_at.iso8601,
|
|
303
|
+
total_files: run.total_files,
|
|
304
|
+
total_tests: results.sum(:total_tests),
|
|
305
|
+
total_passed: results.sum(:passed_tests),
|
|
306
|
+
total_failed: results.sum(:failed_tests),
|
|
307
|
+
total_slow: results.sum(:slow_query_count),
|
|
308
|
+
total_n1: results.sum(:n1_count),
|
|
309
|
+
avg_coverage: (results.average(:coverage)&.round(1) || 0),
|
|
310
|
+
files: results.map { |r|
|
|
311
|
+
{ key: r.file_key, status: r.status, coverage: r.coverage,
|
|
312
|
+
slow: r.slow_query_count, n1: r.n1_count }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
File.write(dir.join("_run_summary.json"), JSON.pretty_generate(summary))
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class FileResult < ActiveRecord::Base
|
|
3
|
+
self.table_name = "devformance_file_results"
|
|
4
|
+
|
|
5
|
+
enum :status, { pending: 0, running: 1, passed: 2, failed: 3 }
|
|
6
|
+
|
|
7
|
+
belongs_to :run, foreign_key: :run_id, primary_key: :run_id,
|
|
8
|
+
class_name: "Devformance::Run"
|
|
9
|
+
|
|
10
|
+
def self.file_key_for(file_path)
|
|
11
|
+
File.basename(file_path, ".rb").gsub(/[^a-z0-9_]/, "_")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class Run < ActiveRecord::Base
|
|
3
|
+
self.table_name = "devformance_runs"
|
|
4
|
+
|
|
5
|
+
enum :status, { pending: 0, running: 1, completed: 2, failed: 3 }
|
|
6
|
+
|
|
7
|
+
has_many :file_results, foreign_key: :run_id, primary_key: :run_id,
|
|
8
|
+
class_name: "Devformance::FileResult"
|
|
9
|
+
|
|
10
|
+
def self.create_for_files(file_paths)
|
|
11
|
+
create!(
|
|
12
|
+
run_id: SecureRandom.hex(8),
|
|
13
|
+
status: :running,
|
|
14
|
+
started_at: Time.current,
|
|
15
|
+
total_files: file_paths.size
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%# Devformance — Parallel Real-Time Dashboard %>
|
|
2
|
+
|
|
3
|
+
<div data-controller="metrics"
|
|
4
|
+
data-metrics-cable-url-value="/devformance/cable"
|
|
5
|
+
data-devformance-mount-path="/devformance">
|
|
6
|
+
|
|
7
|
+
<%# ── Header ── %>
|
|
8
|
+
<div style="margin-bottom:2rem;">
|
|
9
|
+
<div style="display:flex; align-items:flex-end; justify-content:space-between; flex-wrap:wrap; gap:1rem;">
|
|
10
|
+
<div>
|
|
11
|
+
<h2 style="font-size:1.75rem; font-weight:700; color:var(--text-primary); margin:0; letter-spacing:-0.02em; line-height:1.2;">
|
|
12
|
+
Performance Dashboard
|
|
13
|
+
</h2>
|
|
14
|
+
<%# <p style="color:var(--text-secondary); font-size:0.875rem; margin:0.375rem 0 0;">
|
|
15
|
+
One background job per spec file, streaming live output via ActionCable.
|
|
16
|
+
</p> %>
|
|
17
|
+
<p style="font-family:var(--font-mono); font-size:0.7rem; font-weight:600; letter-spacing:0.12em; color:var(--green); text-transform:uppercase; margin:0 0 0.375rem;">
|
|
18
|
+
Performance benchmarking · Integration tests · Queries optimization
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<%# ── Run CTA ── %>
|
|
25
|
+
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem; background:rgba(34,197,94,0.06); border:1px solid rgba(34,197,94,0.2); border-radius:12px; padding:1.125rem 1.5rem; margin-bottom:1.75rem;">
|
|
26
|
+
<div style="display:flex; align-items:center; gap:1rem;">
|
|
27
|
+
<div style="display:flex; align-items:center; justify-content:center; width:40px; height:40px; border-radius:10px; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.25); flex-shrink:0;">
|
|
28
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#22C55E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5,3 19,12 5,21"/></svg>
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<p style="font-size:0.9375rem; font-weight:600; color:var(--text-primary); margin:0 0 0.125rem;">Run all integration tests</p>
|
|
32
|
+
<p style="font-size:0.8125rem; color:var(--text-secondary); margin:0;">Each file gets its own panel with live terminal, slow queries, N+1 detections, and coverage.</p>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<button
|
|
36
|
+
class="btn-primary"
|
|
37
|
+
data-action="click->metrics#runTests"
|
|
38
|
+
data-metrics-target="runBtn">
|
|
39
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5,3 19,12 5,21"/></svg>
|
|
40
|
+
Run performance tests
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<%# ── Summary stat cards ── %>
|
|
45
|
+
<div class="dm-summary" data-metrics-target="summary" style="display:grid; grid-template-columns:repeat(auto-fit, minmax(160px, 1fr)); gap:1rem; margin-bottom:1.75rem;">
|
|
46
|
+
<div class="panel" style="padding:1rem 1.25rem;">
|
|
47
|
+
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.375rem;">Tests run</div>
|
|
48
|
+
<div style="font-size:1.5rem; font-weight:700; color:var(--text-primary);" data-metrics-target="statTotalTests">—</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="panel" style="padding:1rem 1.25rem;">
|
|
51
|
+
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.375rem;">Slow queries</div>
|
|
52
|
+
<div style="font-size:1.5rem; font-weight:700; color:#fbbf24;" data-metrics-target="statSlowQueries">—</div>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="panel" style="padding:1rem 1.25rem;">
|
|
55
|
+
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.375rem;">N+1 issues</div>
|
|
56
|
+
<div style="font-size:1.5rem; font-weight:700; color:#f87171;" data-metrics-target="statN1Issues">—</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="panel" style="padding:1rem 1.25rem;">
|
|
59
|
+
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.375rem;">Avg coverage</div>
|
|
60
|
+
<div style="font-size:1.5rem; font-weight:700; color:#34d399;" data-metrics-target="statCoverage">—</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<%# ── File panels ── %>
|
|
65
|
+
<div class="panel" style="overflow:hidden;">
|
|
66
|
+
<div class="panel-header" style="border-bottom:1px solid rgba(255,255,255,0.05); padding:0.75rem 1.25rem;">
|
|
67
|
+
<h3 style="font-size:0.875rem; font-weight:600; color:var(--text-primary); margin:0; font-family:var(--font-mono);">Integration tests</h3>
|
|
68
|
+
</div>
|
|
69
|
+
<div data-metrics-target="fileList">
|
|
70
|
+
<%# Empty state %>
|
|
71
|
+
<div class="dm-empty">
|
|
72
|
+
<div class="panel-header" style="border-bottom:1px solid rgba(255,255,255,0.05); padding:0.75rem 1.25rem;">
|
|
73
|
+
<div class="dm-empty-text" style="font-size:0.875rem; color:var(--text-secondary);">No test files found. Run performance tests to see results here.</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<div data-controller="playground" class="space-y-6">
|
|
2
|
+
<div class="flex items-center justify-between mb-4">
|
|
3
|
+
<h2 class="text-2xl font-bold text-gray-900 leading-tight">Query Playground</h2>
|
|
4
|
+
<a href="/devformance" class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
|
5
|
+
<svg class="mr-1.5 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
6
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
7
|
+
</svg>
|
|
8
|
+
Back to Dashboard
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="bg-white rounded-lg shadow border border-gray-100 p-6">
|
|
13
|
+
<form data-action="submit->playground#execute">
|
|
14
|
+
<div class="mb-4">
|
|
15
|
+
<label for="query" class="block text-sm font-medium text-gray-700 mb-2">Execute ActiveRecord Query</label>
|
|
16
|
+
<div class="mt-1 relative rounded-md shadow-sm border border-gray-300 focus-within:ring-1 focus-within:ring-indigo-500 focus-within:border-indigo-500">
|
|
17
|
+
<textarea id="query" name="query" rows="4" class="form-textarea block w-full rounded-md border-0 py-3 px-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm font-mono" placeholder="User.includes(:posts).limit(10).to_a" data-playground-target="input" required></textarea>
|
|
18
|
+
</div>
|
|
19
|
+
<p class="mt-2 text-sm text-gray-500">Enter valid Ruby/ActiveRecord code. E.g., <code class="bg-gray-100 text-gray-800 px-1 py-0.5 rounded">QueryLog.all.to_a</code></p>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="flex items-center justify-between">
|
|
23
|
+
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-playground-target="submitBtn">
|
|
24
|
+
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
25
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
26
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
27
|
+
</svg>
|
|
28
|
+
Run Query
|
|
29
|
+
</button>
|
|
30
|
+
|
|
31
|
+
<div class="hidden items-center space-x-2 text-sm font-medium text-gray-500" data-playground-target="spinner">
|
|
32
|
+
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
33
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
34
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
35
|
+
</svg>
|
|
36
|
+
Executing...
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</form>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="hidden bg-white rounded-lg shadow border border-gray-100 p-6 transition-all duration-300 ease-in-out" data-playground-target="resultContainer">
|
|
43
|
+
<h3 class="text-lg font-medium text-gray-900 mb-4 border-b border-gray-100 pb-2">Execution Results</h3>
|
|
44
|
+
|
|
45
|
+
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
46
|
+
<div class="bg-gray-50 p-3 rounded-md border border-gray-200">
|
|
47
|
+
<span class="block text-xs font-semibold text-gray-500 uppercase tracking-wide">Duration</span>
|
|
48
|
+
<span class="mt-1 text-lg font-medium text-gray-900" data-playground-target="durationStat">-- ms</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="bg-gray-50 p-3 rounded-md border border-gray-200">
|
|
51
|
+
<span class="block text-xs font-semibold text-gray-500 uppercase tracking-wide">Status</span>
|
|
52
|
+
<span class="mt-1 text-lg font-medium text-green-600" data-playground-target="statusStat">Success</span>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div>
|
|
57
|
+
<h4 class="text-sm font-semibold text-gray-700 mb-2">Output Logs / Result</h4>
|
|
58
|
+
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto">
|
|
59
|
+
<pre class="text-sm text-green-400 font-mono whitespace-pre-wrap" data-playground-target="output">Ready for input...</pre>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|