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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +205 -0
  3. data/app/assets/builds/tailwind.css +2 -0
  4. data/app/assets/images/icon.png +0 -0
  5. data/app/assets/images/icon.svg +68 -0
  6. data/app/assets/stylesheets/devmetrics/dashboard.css +476 -0
  7. data/app/assets/stylesheets/devmetrics_live/application.css +10 -0
  8. data/app/assets/tailwind/application.css +1 -0
  9. data/app/channels/application_cable/channel.rb +4 -0
  10. data/app/channels/application_cable/connection.rb +4 -0
  11. data/app/channels/devformance/metrics_channel.rb +25 -0
  12. data/app/controllers/application_controller.rb +4 -0
  13. data/app/controllers/devformance/application_controller.rb +19 -0
  14. data/app/controllers/devformance/icons_controller.rb +21 -0
  15. data/app/controllers/devformance/metrics_controller.rb +41 -0
  16. data/app/controllers/devformance/playground_controller.rb +89 -0
  17. data/app/helpers/application_helper.rb +9 -0
  18. data/app/helpers/metrics_helper.rb +2 -0
  19. data/app/helpers/playground_helper.rb +2 -0
  20. data/app/javascript/devformance/channels/consumer.js +2 -0
  21. data/app/javascript/devformance/channels/index.js +1 -0
  22. data/app/javascript/devformance/controllers/application.js +9 -0
  23. data/app/javascript/devformance/controllers/hello_controller.js +7 -0
  24. data/app/javascript/devformance/controllers/index.js +14 -0
  25. data/app/javascript/devformance/controllers/metrics_controller.js +364 -0
  26. data/app/javascript/devformance/controllers/playground_controller.js +33 -0
  27. data/app/javascript/devmetrics.js +4 -0
  28. data/app/jobs/application_job.rb +7 -0
  29. data/app/jobs/devformance/file_runner_job.rb +318 -0
  30. data/app/mailers/application_mailer.rb +4 -0
  31. data/app/models/application_record.rb +3 -0
  32. data/app/models/devformance/file_result.rb +14 -0
  33. data/app/models/devformance/run.rb +19 -0
  34. data/app/models/devformance/slow_query.rb +5 -0
  35. data/app/views/devformance/metrics/index.html.erb +79 -0
  36. data/app/views/devformance/playground/run.html.erb +63 -0
  37. data/app/views/layouts/devformance/application.html.erb +856 -0
  38. data/app/views/layouts/mailer.html.erb +13 -0
  39. data/app/views/layouts/mailer.text.erb +1 -0
  40. data/app/views/metrics/index.html.erb +334 -0
  41. data/app/views/pwa/manifest.json.erb +22 -0
  42. data/app/views/pwa/service-worker.js +26 -0
  43. data/config/BUSINESS_LOGIC_PLAN.md +1244 -0
  44. data/config/application.rb +31 -0
  45. data/config/boot.rb +4 -0
  46. data/config/cable.yml +17 -0
  47. data/config/cache.yml +16 -0
  48. data/config/credentials.yml.enc +1 -0
  49. data/config/database.yml +98 -0
  50. data/config/deploy.yml +116 -0
  51. data/config/engine_routes.rb +13 -0
  52. data/config/environment.rb +5 -0
  53. data/config/environments/development.rb +84 -0
  54. data/config/environments/production.rb +90 -0
  55. data/config/environments/test.rb +59 -0
  56. data/config/importmap.rb +11 -0
  57. data/config/initializers/assets.rb +7 -0
  58. data/config/initializers/content_security_policy.rb +25 -0
  59. data/config/initializers/filter_parameter_logging.rb +8 -0
  60. data/config/initializers/inflections.rb +16 -0
  61. data/config/locales/en.yml +31 -0
  62. data/config/master.key +1 -0
  63. data/config/puma.rb +41 -0
  64. data/config/queue.yml +22 -0
  65. data/config/recurring.yml +15 -0
  66. data/config/routes.rb +20 -0
  67. data/config/storage.yml +34 -0
  68. data/db/migrate/20260317144616_create_slow_queries.rb +13 -0
  69. data/db/migrate/20260317175630_create_performance_runs.rb +14 -0
  70. data/db/migrate/20260317195043_add_run_id_to_slow_queries.rb +10 -0
  71. data/db/migrate/20260319000001_create_devformance_runs.rb +20 -0
  72. data/db/migrate/20260319000002_create_devformance_file_results.rb +29 -0
  73. data/db/migrate/20260319000003_add_columns_to_slow_queries.rb +7 -0
  74. data/lib/devformance/bullet_log_parser.rb +47 -0
  75. data/lib/devformance/compatibility.rb +12 -0
  76. data/lib/devformance/coverage_setup.rb +33 -0
  77. data/lib/devformance/engine.rb +80 -0
  78. data/lib/devformance/log_writer.rb +29 -0
  79. data/lib/devformance/run_orchestrator.rb +58 -0
  80. data/lib/devformance/sql_instrumentor.rb +29 -0
  81. data/lib/devformance/test_framework/base.rb +43 -0
  82. data/lib/devformance/test_framework/coverage_helper.rb +76 -0
  83. data/lib/devformance/test_framework/detector.rb +26 -0
  84. data/lib/devformance/test_framework/minitest.rb +71 -0
  85. data/lib/devformance/test_framework/registry.rb +24 -0
  86. data/lib/devformance/test_framework/rspec.rb +60 -0
  87. data/lib/devformance/test_helper.rb +42 -0
  88. data/lib/devformance/version.rb +3 -0
  89. data/lib/devformance.rb +196 -0
  90. data/lib/generators/devformance/install/install_generator.rb +73 -0
  91. data/lib/generators/devformance/install/templates/add_columns_to_slow_queries.rb.erb +7 -0
  92. data/lib/generators/devformance/install/templates/add_run_id_to_slow_queries.rb.erb +10 -0
  93. data/lib/generators/devformance/install/templates/create_devformance_file_results.rb.erb +29 -0
  94. data/lib/generators/devformance/install/templates/create_devformance_runs.rb.erb +20 -0
  95. data/lib/generators/devformance/install/templates/create_performance_runs.rb.erb +14 -0
  96. data/lib/generators/devformance/install/templates/create_slow_queries.rb.erb +13 -0
  97. data/lib/generators/devformance/install/templates/initializer.rb +23 -0
  98. data/lib/tasks/devformance.rake +45 -0
  99. data/spec/fixtures/devformance/devformance_run.rb +27 -0
  100. data/spec/fixtures/devformance/file_result.rb +34 -0
  101. data/spec/fixtures/devformance/slow_query.rb +11 -0
  102. data/spec/lib/devmetrics/log_writer_spec.rb +81 -0
  103. data/spec/lib/devmetrics/run_orchestrator_spec.rb +102 -0
  104. data/spec/lib/devmetrics/sql_instrumentor_spec.rb +115 -0
  105. data/spec/models/devmetrics/file_result_spec.rb +87 -0
  106. data/spec/models/devmetrics/run_spec.rb +66 -0
  107. data/spec/models/query_log_spec.rb +21 -0
  108. data/spec/rails_helper.rb +20 -0
  109. data/spec/requests/devmetrics/metrics_controller_spec.rb +149 -0
  110. data/spec/requests/devmetrics_pages_spec.rb +12 -0
  111. data/spec/requests/performance_spec.rb +17 -0
  112. data/spec/requests/slow_perf_spec.rb +9 -0
  113. data/spec/spec_helper.rb +114 -0
  114. data/spec/support/devmetrics_formatter.rb +106 -0
  115. data/spec/support/devmetrics_metrics.rb +37 -0
  116. data/spec/support/factory_bot.rb +3 -0
  117. 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,4 @@
1
+ class ApplicationMailer < ActionMailer::Base
2
+ default from: "from@example.com"
3
+ layout "mailer"
4
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ primary_abstract_class
3
+ 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,5 @@
1
+ module Devformance
2
+ class SlowQuery < ActiveRecord::Base
3
+ self.table_name = "slow_queries"
4
+ end
5
+ 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>