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,1244 @@
1
+ # DevMetrics — Parallel Real-Time Test Runner
2
+ ## Full Implementation Plan for Claude Code
3
+
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Replace the single sequential `PerformanceTestRunnerJob` with a **fan-out architecture**: one
9
+ background job per spec file, all running in parallel, each streaming live output to its own
10
+ panel in the browser via ActionCable. Every panel shows a live terminal, slow queries, N+1
11
+ detections, and per-file coverage as they happen.
12
+
13
+ ---
14
+
15
+ ## Architecture Summary
16
+
17
+ ```
18
+ POST /devmetrics/run_tests
19
+ → RunOrchestrator.call
20
+ → Dir.glob spec/requests/**/*_spec.rb
21
+ → creates Run record (run_id, status: :running)
22
+ → enqueues FileRunnerJob per file (all at once)
23
+ → broadcasts { type: "run_started", files: [...file_keys] }
24
+
25
+ FileRunnerJob (N jobs in parallel, one per file)
26
+ → Open3.popen2e("bundle exec rspec <file> ...")
27
+ → ActiveSupport::Notifications subscriber (scoped to job thread)
28
+ → LogWriter appends to log/devmetrics/runs/<run_id>/<file_key>.log
29
+ → broadcasts per-file events to "devmetrics:file:<file_key>"
30
+
31
+ Browser (Stimulus metrics_controller.js)
32
+ → subscribes to "devmetrics:run" for run_started
33
+ → on run_started: creates panel + subscription per file_key
34
+ → each subscription handles: test_output, slow_query, n1_detected,
35
+ coverage_update, file_complete
36
+ → panels collapse/expand, progress bar, live terminal, sidebar stats
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Phase 1 — Database & Models
42
+
43
+ ### Step 1.1 — Generate migrations
44
+
45
+ ```bash
46
+ rails g devmetrics:install # already exists — skip if done
47
+ ```
48
+
49
+ Generate new migration inside the gem:
50
+
51
+ ```bash
52
+ # From the gem root
53
+ rails g migration CreateDevmetricsRuns \
54
+ run_id:string status:integer started_at:datetime finished_at:datetime \
55
+ total_files:integer completed_files:integer --no-test-framework
56
+ ```
57
+
58
+ ```bash
59
+ rails g migration CreateDevmetricsFileResults \
60
+ run_id:string file_key:string file_path:string status:integer \
61
+ total_tests:integer passed_tests:integer failed_tests:integer \
62
+ slow_query_count:integer n1_count:integer coverage:float \
63
+ duration_ms:integer log_path:string --no-test-framework
64
+ ```
65
+
66
+ ### Step 1.2 — Models
67
+
68
+ **`lib/devmetrics/models/run.rb`**
69
+
70
+ ```ruby
71
+ module Devmetrics
72
+ class Run < ActiveRecord::Base
73
+ self.table_name = "devmetrics_runs"
74
+
75
+ enum :status, { pending: 0, running: 1, completed: 2, failed: 3 }
76
+
77
+ has_many :file_results, foreign_key: :run_id, primary_key: :run_id,
78
+ class_name: "Devmetrics::FileResult"
79
+
80
+ def self.create_for_files(file_paths)
81
+ create!(
82
+ run_id: SecureRandom.hex(8),
83
+ status: :running,
84
+ started_at: Time.current,
85
+ total_files: file_paths.size
86
+ )
87
+ end
88
+ end
89
+ end
90
+ ```
91
+
92
+ **`lib/devmetrics/models/file_result.rb`**
93
+
94
+ ```ruby
95
+ module Devmetrics
96
+ class FileResult < ActiveRecord::Base
97
+ self.table_name = "devmetrics_file_results"
98
+
99
+ enum :status, { pending: 0, running: 1, passed: 2, failed: 3 }
100
+
101
+ belongs_to :run, foreign_key: :run_id, primary_key: :run_id,
102
+ class_name: "Devmetrics::Run"
103
+
104
+ def self.file_key_for(file_path)
105
+ File.basename(file_path, ".rb").gsub(/[^a-z0-9_]/, "_")
106
+ end
107
+ end
108
+ end
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Phase 2 — Core Backend Services
114
+
115
+ ### Step 2.1 — LogWriter
116
+
117
+ **`lib/devmetrics/log_writer.rb`**
118
+
119
+ ```ruby
120
+ module Devmetrics
121
+ class LogWriter
122
+ LOG_BASE = Rails.root.join("log", "devmetrics", "runs")
123
+
124
+ def self.open(run_id, file_key)
125
+ dir = LOG_BASE.join(run_id.to_s)
126
+ FileUtils.mkdir_p(dir)
127
+ new(dir.join("#{file_key}.log"))
128
+ end
129
+
130
+ def initialize(path)
131
+ @path = path
132
+ @file = File.open(path, "w")
133
+ end
134
+
135
+ def write(line)
136
+ @file.puts(line)
137
+ @file.flush
138
+ end
139
+
140
+ def close
141
+ @file.close
142
+ end
143
+
144
+ def path
145
+ @path.to_s
146
+ end
147
+ end
148
+ end
149
+ ```
150
+
151
+ ### Step 2.2 — SqlInstrumentor
152
+
153
+ Scoped per-job SQL tracking using thread-local state so parallel jobs don't bleed into each other.
154
+
155
+ **`lib/devmetrics/sql_instrumentor.rb`**
156
+
157
+ ```ruby
158
+ module Devmetrics
159
+ class SqlInstrumentor
160
+ THREAD_KEY = :devmetrics_sql_collector
161
+
162
+ def self.around_run
163
+ Thread.current[THREAD_KEY] = { queries: [], start: Time.current }
164
+ yield
165
+ ensure
166
+ Thread.current[THREAD_KEY] = nil
167
+ end
168
+
169
+ def self.record(event)
170
+ collector = Thread.current[THREAD_KEY]
171
+ return unless collector
172
+
173
+ ms = event.duration.round(2)
174
+ sql = event.payload[:sql].to_s.strip
175
+
176
+ return if sql.match?(/\A(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i)
177
+
178
+ collector[:queries] << { sql: sql, ms: ms, at: Time.current.iso8601 }
179
+ collector[:queries].last
180
+ end
181
+
182
+ def self.queries
183
+ Thread.current[THREAD_KEY]&.dig(:queries) || []
184
+ end
185
+ end
186
+ end
187
+ ```
188
+
189
+ Register the subscriber once in the engine initializer (not per-job):
190
+
191
+ ```ruby
192
+ # lib/devmetrics/engine.rb (inside the existing Engine class)
193
+ initializer "devmetrics.sql_notifications" do
194
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
195
+ event = ActiveSupport::Notifications::Event.new(*args)
196
+ ::Devmetrics::SqlInstrumentor.record(event)
197
+ end
198
+ end
199
+ ```
200
+
201
+ ### Step 2.3 — FileRunnerJob
202
+
203
+ **`lib/devmetrics/jobs/file_runner_job.rb`**
204
+
205
+ ```ruby
206
+ module Devmetrics
207
+ class FileRunnerJob < ActiveJob::Base
208
+ queue_as :devmetrics
209
+
210
+ SLOW_THRESHOLD_MS = -> { Devmetrics.configuration.slow_query_threshold_ms }
211
+
212
+ def perform(run_id:, file_path:, file_key:)
213
+ result = ::Devmetrics::FileResult.find_by!(run_id: run_id, file_key: file_key)
214
+ result.update!(status: :running)
215
+
216
+ log = ::Devmetrics::LogWriter.open(run_id, file_key)
217
+ stream = "devmetrics:file:#{file_key}:#{run_id}"
218
+
219
+ broadcast(stream, type: "file_started", file_key: file_key)
220
+
221
+ ::Devmetrics::SqlInstrumentor.around_run do
222
+ run_rspec(file_path, stream, log, result)
223
+ end
224
+
225
+ flush_sql_results(stream, log, result)
226
+ write_coverage(stream, log, result, file_path)
227
+ finalize(stream, log, result, run_id)
228
+ rescue => e
229
+ broadcast(stream, type: "file_error", message: e.message)
230
+ result&.update!(status: :failed)
231
+ ensure
232
+ log&.close
233
+ end
234
+
235
+ private
236
+
237
+ def run_rspec(file_path, stream, log, result)
238
+ cmd = [
239
+ "bundle", "exec", "rspec", file_path,
240
+ "--format", "documentation",
241
+ "--format", "json",
242
+ "--out", json_output_path(result.run_id, result.file_key)
243
+ ]
244
+
245
+ passed = 0
246
+ failed = 0
247
+
248
+ Open3.popen2e(*cmd, chdir: Rails.root.to_s) do |_stdin, stdout_err, wait_thr|
249
+ stdout_err.each_line do |raw_line|
250
+ line = raw_line.chomp
251
+ log.write(line)
252
+
253
+ event_type = classify_line(line)
254
+ broadcast(stream, type: "test_output", line: line, event_type: event_type)
255
+
256
+ passed += 1 if event_type == "pass"
257
+ failed += 1 if event_type == "fail"
258
+ end
259
+
260
+ exit_status = wait_thr.value
261
+ status = exit_status.success? && failed == 0 ? :passed : :failed
262
+ result.update!(
263
+ status: status,
264
+ passed_tests: passed,
265
+ failed_tests: failed,
266
+ total_tests: passed + failed
267
+ )
268
+ end
269
+ end
270
+
271
+ def flush_sql_results(stream, log, result)
272
+ queries = ::Devmetrics::SqlInstrumentor.queries
273
+ slow = queries.select { |q| q[:ms] >= SLOW_THRESHOLD_MS.call }
274
+ n1_groups = detect_n1_patterns(queries)
275
+
276
+ slow.each do |q|
277
+ entry = { sql: q[:sql].truncate(200), ms: q[:ms] }
278
+ log.write(" [SLOW #{q[:ms]}ms] #{q[:sql].truncate(120)}")
279
+ broadcast(stream, type: "slow_query", query: entry)
280
+
281
+ ::Devmetrics::SlowQuery.create!(
282
+ run_id: result.run_id,
283
+ file_key: result.file_key,
284
+ sql: q[:sql],
285
+ duration_ms: q[:ms]
286
+ )
287
+ end
288
+
289
+ n1_groups.each do |pattern, count|
290
+ msg = "N+1 detected: #{pattern} (#{count}x) — add includes(:#{n1_association(pattern)})"
291
+ log.write(" [N+1] #{msg}")
292
+ broadcast(stream, type: "n1_detected", message: msg, pattern: pattern, count: count)
293
+ end
294
+
295
+ result.update!(
296
+ slow_query_count: slow.size,
297
+ n1_count: n1_groups.size
298
+ )
299
+ end
300
+
301
+ def write_coverage(stream, log, result, file_path)
302
+ json_path = json_output_path(result.run_id, result.file_key)
303
+ return unless File.exist?(json_path)
304
+
305
+ data = JSON.parse(File.read(json_path)) rescue {}
306
+ summary = data.dig("summary", "example_count")
307
+ return unless summary
308
+
309
+ # SimpleCov resultset if present
310
+ resultset_path = Rails.root.join("coverage", ".resultset.json")
311
+ if File.exist?(resultset_path)
312
+ rs = JSON.parse(File.read(resultset_path)) rescue {}
313
+ pct = extract_coverage_pct(rs, file_path)
314
+ if pct
315
+ log.write(" [COVERAGE] #{pct.round(1)}%")
316
+ broadcast(stream, type: "coverage_update", pct: pct.round(1))
317
+ result.update!(coverage: pct.round(1))
318
+ end
319
+ end
320
+ end
321
+
322
+ def finalize(stream, log, result, run_id)
323
+ log.write("")
324
+ log.write("=" * 60)
325
+ log.write("Result: #{result.status.upcase} | " \
326
+ "#{result.total_tests} tests, #{result.failed_tests} failures | " \
327
+ "#{result.slow_query_count} slow queries | #{result.n1_count} N+1 issues")
328
+
329
+ broadcast(stream, type: "file_complete",
330
+ status: result.status,
331
+ passed: result.passed_tests,
332
+ failed: result.failed_tests,
333
+ slow_count: result.slow_query_count,
334
+ n1_count: result.n1_count,
335
+ coverage: result.coverage,
336
+ duration_ms: result.duration_ms,
337
+ log_path: result.log_path
338
+ )
339
+
340
+ # Increment run-level counter and check if all files done
341
+ run = ::Devmetrics::Run.find_by(run_id: run_id)
342
+ if run
343
+ run.with_lock do
344
+ run.increment!(:completed_files)
345
+ if run.completed_files >= run.total_files
346
+ run.update!(status: :completed, finished_at: Time.current)
347
+ ActionCable.server.broadcast(
348
+ "devmetrics:run:#{run_id}",
349
+ type: "run_complete",
350
+ run_id: run_id,
351
+ total_files: run.total_files
352
+ )
353
+ write_run_summary(run)
354
+ end
355
+ end
356
+ end
357
+ end
358
+
359
+ def broadcast(stream, payload)
360
+ ActionCable.server.broadcast(stream, payload)
361
+ end
362
+
363
+ def classify_line(line)
364
+ if line.match?(/^\s+\d+ examples?,/)
365
+ "summary"
366
+ elsif line.match?(/^\s*[·.]\s/) || line.strip.start_with?(".")
367
+ "pass"
368
+ elsif line.match?(/^\s*[F!]\s/) || line.strip.start_with?("F")
369
+ "fail"
370
+ elsif line.match?(/^\s*\*\s/) || line.strip.start_with?("*")
371
+ "pending"
372
+ elsif line.include?("ERROR") || line.include?("Error:")
373
+ "error"
374
+ else
375
+ "info"
376
+ end
377
+ end
378
+
379
+ def detect_n1_patterns(queries)
380
+ pattern_counts = Hash.new(0)
381
+ queries.each do |q|
382
+ normalized = q[:sql].gsub(/\d+/, "?").gsub(/'[^']*'/, "?").strip
383
+ pattern_counts[normalized] += 1
384
+ end
385
+ pattern_counts.select { |_, count| count >= 3 }
386
+ end
387
+
388
+ def n1_association(pattern)
389
+ pattern.match(/FROM "?(\w+)"?/i)&.captures&.first&.singularize || "association"
390
+ end
391
+
392
+ def extract_coverage_pct(resultset, file_path)
393
+ rel = file_path.sub(Rails.root.to_s + "/", "")
394
+ all_lines = resultset.values.flat_map { |r| r.dig("coverage", rel)&.compact }.compact
395
+ return nil if all_lines.empty?
396
+ covered = all_lines.count { |v| v.to_i > 0 }
397
+ (covered.to_f / all_lines.size * 100).round(1)
398
+ end
399
+
400
+ def json_output_path(run_id, file_key)
401
+ dir = Rails.root.join("log", "devmetrics", "runs", run_id.to_s)
402
+ FileUtils.mkdir_p(dir)
403
+ dir.join("#{file_key}.json").to_s
404
+ end
405
+
406
+ def write_run_summary(run)
407
+ results = run.file_results
408
+ dir = Rails.root.join("log", "devmetrics", "runs", run.run_id.to_s)
409
+ summary = {
410
+ run_id: run.run_id,
411
+ started_at: run.started_at.iso8601,
412
+ finished_at: run.finished_at.iso8601,
413
+ total_files: run.total_files,
414
+ total_tests: results.sum(:total_tests),
415
+ total_passed: results.sum(:passed_tests),
416
+ total_failed: results.sum(:failed_tests),
417
+ total_slow: results.sum(:slow_query_count),
418
+ total_n1: results.sum(:n1_count),
419
+ avg_coverage: (results.average(:coverage)&.round(1) || 0),
420
+ files: results.map { |r|
421
+ { key: r.file_key, status: r.status, coverage: r.coverage,
422
+ slow: r.slow_query_count, n1: r.n1_count }
423
+ }
424
+ }
425
+ File.write(dir.join("_run_summary.json"), JSON.pretty_generate(summary))
426
+ end
427
+ end
428
+ end
429
+ ```
430
+
431
+ ### Step 2.4 — RunOrchestrator
432
+
433
+ **`lib/devmetrics/run_orchestrator.rb`**
434
+
435
+ ```ruby
436
+ module Devmetrics
437
+ class RunOrchestrator
438
+ SPEC_GLOB = "spec/requests/**/*_spec.rb"
439
+
440
+ def self.call
441
+ new.call
442
+ end
443
+
444
+ def call
445
+ file_paths = discover_files
446
+ return { error: "No request specs found in #{SPEC_GLOB}" } if file_paths.empty?
447
+
448
+ run = ::Devmetrics::Run.create_for_files(file_paths)
449
+
450
+ file_metas = file_paths.map do |path|
451
+ file_key = ::Devmetrics::FileResult.file_key_for(path)
452
+ ::Devmetrics::FileResult.create!(
453
+ run_id: run.run_id,
454
+ file_key: file_key,
455
+ file_path: path,
456
+ status: :pending
457
+ )
458
+ { file_key: file_key, file_path: path, display_name: path.sub(Rails.root.to_s + "/", "") }
459
+ end
460
+
461
+ ActionCable.server.broadcast(
462
+ "devmetrics:run:#{run.run_id}",
463
+ type: "run_started",
464
+ run_id: run.run_id,
465
+ files: file_metas
466
+ )
467
+
468
+ file_metas.each do |meta|
469
+ ::Devmetrics::FileRunnerJob.perform_later(
470
+ run_id: run.run_id,
471
+ file_path: meta[:file_path],
472
+ file_key: meta[:file_key]
473
+ )
474
+ end
475
+
476
+ { run_id: run.run_id, file_count: file_metas.size, files: file_metas }
477
+ end
478
+
479
+ private
480
+
481
+ def discover_files
482
+ all = Dir.glob(Rails.root.join(SPEC_GLOB)).sort
483
+
484
+ # Prefer files tagged with devmetrics (require 'devmetrics' or devmetrics: true)
485
+ tagged = all.select { |f| File.read(f).match?(/devmetrics/i) }
486
+ tagged.any? ? tagged : all
487
+ end
488
+ end
489
+ end
490
+ ```
491
+
492
+ ---
493
+
494
+ ## Phase 3 — ActionCable Channel
495
+
496
+ ### Step 3.1 — Update MetricsChannel
497
+
498
+ **`app/channels/devmetrics/metrics_channel.rb`**
499
+
500
+ ```ruby
501
+ module Devmetrics
502
+ class MetricsChannel < ActionCable::Channel::Base
503
+ def subscribed
504
+ case params[:stream_type]
505
+ when "run"
506
+ run_id = params[:run_id]
507
+ stream_from "devmetrics:run:#{run_id}" if run_id.present?
508
+ when "file"
509
+ file_key = params[:file_key]
510
+ run_id = params[:run_id]
511
+ if file_key.present? && run_id.present?
512
+ stream_from "devmetrics:file:#{file_key}:#{run_id}"
513
+ end
514
+ else
515
+ # Legacy support — global stream
516
+ stream_from "devmetrics:metrics"
517
+ end
518
+ end
519
+
520
+ def unsubscribed
521
+ stop_all_streams
522
+ end
523
+ end
524
+ end
525
+ ```
526
+
527
+ ---
528
+
529
+ ## Phase 4 — Controller
530
+
531
+ ### Step 4.1 — MetricsController#run_tests
532
+
533
+ Replace the existing `run_tests` action:
534
+
535
+ ```ruby
536
+ # app/controllers/devmetrics/metrics_controller.rb
537
+
538
+ def run_tests
539
+ result = ::Devmetrics::RunOrchestrator.call
540
+ if result[:error]
541
+ render json: { error: result[:error] }, status: :unprocessable_entity
542
+ else
543
+ render json: result, status: :accepted
544
+ end
545
+ end
546
+
547
+ def run_status
548
+ run = ::Devmetrics::Run.find_by(run_id: params[:run_id])
549
+ return render json: { error: "Not found" }, status: :not_found unless run
550
+
551
+ render json: {
552
+ run_id: run.run_id,
553
+ status: run.status,
554
+ files: run.file_results.map { |r|
555
+ { file_key: r.file_key, file_path: r.file_path, status: r.status,
556
+ coverage: r.coverage, slow_query_count: r.slow_query_count, n1_count: r.n1_count }
557
+ }
558
+ }
559
+ end
560
+
561
+ def download_log
562
+ result = ::Devmetrics::FileResult.find_by(
563
+ run_id: params[:run_id], file_key: params[:file_key]
564
+ )
565
+ return render plain: "Not found", status: :not_found unless result&.log_path
566
+ return render plain: "Log not ready", status: :not_found unless File.exist?(result.log_path)
567
+
568
+ send_file result.log_path, type: "text/plain", disposition: "attachment"
569
+ end
570
+ ```
571
+
572
+ ### Step 4.2 — Routes
573
+
574
+ ```ruby
575
+ # In the engine's routes.rb
576
+ Devmetrics::Engine.routes.draw do
577
+ root to: "metrics#index"
578
+
579
+ get "playground", to: "metrics#playground"
580
+ post "run_tests", to: "metrics#run_tests"
581
+ post "playground", to: "metrics#playground_execute"
582
+ get "runs/:run_id/status", to: "metrics#run_status"
583
+ get "runs/:run_id/logs/:file_key/download", to: "metrics#download_log"
584
+
585
+ mount ActionCable.server => "/cable"
586
+ end
587
+ ```
588
+
589
+ ---
590
+
591
+ ## Phase 5 — Frontend (Stimulus + ActionCable)
592
+
593
+ ### Step 5.1 — HTML template
594
+
595
+ **`app/views/devmetrics/metrics/index.html.erb`**
596
+
597
+ Key structure only — fill in your existing layout wrapper:
598
+
599
+ ```erb
600
+ <div data-controller="metrics"
601
+ data-metrics-cable-url-value="<%= devmetrics_mount_path %>/cable">
602
+
603
+ <%# Header + controls %>
604
+ <div class="dm-header">
605
+ <h1>DevMetrics</h1>
606
+ <button data-action="click->metrics#runTests" data-metrics-target="runBtn">
607
+ Run performance tests
608
+ </button>
609
+ </div>
610
+
611
+ <%# Summary stat cards — updated live %>
612
+ <div class="dm-summary" data-metrics-target="summary">
613
+ <div class="dm-stat" data-stat="totalTests">
614
+ <div class="dm-stat-label">Tests run</div>
615
+ <div class="dm-stat-value" data-metrics-target="statTotalTests">—</div>
616
+ </div>
617
+ <div class="dm-stat" data-stat="slowQueries">
618
+ <div class="dm-stat-label">Slow queries</div>
619
+ <div class="dm-stat-value" data-metrics-target="statSlowQueries">—</div>
620
+ </div>
621
+ <div class="dm-stat" data-stat="n1Issues">
622
+ <div class="dm-stat-label">N+1 issues</div>
623
+ <div class="dm-stat-value" data-metrics-target="statN1Issues">—</div>
624
+ </div>
625
+ <div class="dm-stat" data-stat="coverage">
626
+ <div class="dm-stat-label">Avg coverage</div>
627
+ <div class="dm-stat-value" data-metrics-target="statCoverage">—</div>
628
+ </div>
629
+ </div>
630
+
631
+ <%# File panels — injected dynamically by JS %>
632
+ <div class="dm-file-list" data-metrics-target="fileList"></div>
633
+
634
+ </div>
635
+ ```
636
+
637
+ ### Step 5.2 — Stimulus controller
638
+
639
+ **`app/javascript/devmetrics/controllers/metrics_controller.js`**
640
+
641
+ ```javascript
642
+ import { Controller } from "@hotwired/stimulus"
643
+ import consumer from "../channels/consumer"
644
+
645
+ export default class extends Controller {
646
+ static targets = [
647
+ "runBtn", "fileList", "summary",
648
+ "statTotalTests", "statSlowQueries", "statN1Issues", "statCoverage"
649
+ ]
650
+
651
+ static values = { cableUrl: String }
652
+
653
+ connect() {
654
+ this.subscriptions = {} // file_key -> ActionCable subscription
655
+ this.runSubscription = null
656
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 }
657
+ this.currentRunId = null
658
+ }
659
+
660
+ disconnect() {
661
+ this.teardownSubscriptions()
662
+ }
663
+
664
+ // ── Run trigger ──────────────────────────────────────────────────────────
665
+
666
+ async runTests() {
667
+ this.runBtnTarget.disabled = true
668
+ this.runBtnTarget.textContent = "Starting…"
669
+ this.resetState()
670
+
671
+ try {
672
+ const resp = await fetch(this.runTestsUrl, {
673
+ method: "POST",
674
+ headers: { "X-CSRF-Token": this.csrfToken, "Content-Type": "application/json" }
675
+ })
676
+ const data = await resp.json()
677
+ if (!resp.ok) throw new Error(data.error || "Failed to start run")
678
+
679
+ this.currentRunId = data.run_id
680
+ this.subscribeToRun(data.run_id)
681
+
682
+ } catch (err) {
683
+ this.runBtnTarget.disabled = false
684
+ this.runBtnTarget.textContent = "Run performance tests"
685
+ console.error("DevMetrics run error:", err)
686
+ }
687
+ }
688
+
689
+ // ── Run-level subscription ────────────────────────────────────────────────
690
+
691
+ subscribeToRun(runId) {
692
+ this.runSubscription = consumer.subscriptions.create(
693
+ { channel: "Devmetrics::MetricsChannel", stream_type: "run", run_id: runId },
694
+ {
695
+ received: (data) => this.handleRunEvent(data)
696
+ }
697
+ )
698
+ }
699
+
700
+ handleRunEvent(data) {
701
+ switch (data.type) {
702
+ case "run_started":
703
+ this.runBtnTarget.textContent = `Running (${data.files.length} files)…`
704
+ data.files.forEach(f => {
705
+ this.createFilePanel(f.file_key, f.display_name, data.run_id)
706
+ this.subscribeToFile(f.file_key, data.run_id)
707
+ })
708
+ break
709
+
710
+ case "run_complete":
711
+ this.runBtnTarget.disabled = false
712
+ this.runBtnTarget.textContent = "Run performance tests"
713
+ this.runSubscription?.unsubscribe()
714
+ break
715
+ }
716
+ }
717
+
718
+ // ── Per-file subscription ─────────────────────────────────────────────────
719
+
720
+ subscribeToFile(fileKey, runId) {
721
+ const sub = consumer.subscriptions.create(
722
+ {
723
+ channel: "Devmetrics::MetricsChannel",
724
+ stream_type: "file",
725
+ file_key: fileKey,
726
+ run_id: runId
727
+ },
728
+ { received: (data) => this.handleFileEvent(fileKey, data) }
729
+ )
730
+ this.subscriptions[fileKey] = sub
731
+ }
732
+
733
+ handleFileEvent(fileKey, data) {
734
+ switch (data.type) {
735
+ case "file_started":
736
+ this.setFileDot(fileKey, "running")
737
+ break
738
+
739
+ case "test_output":
740
+ this.appendTerminalLine(fileKey, data.line, data.event_type)
741
+ if (data.event_type === "pass" || data.event_type === "fail") {
742
+ this.advanceProgress(fileKey)
743
+ }
744
+ break
745
+
746
+ case "slow_query":
747
+ this.appendSidebarItem(fileKey, "slow", `${data.query.ms}ms — ${data.query.sql}`)
748
+ this.stats.slow++
749
+ this.updateSummaryStats()
750
+ break
751
+
752
+ case "n1_detected":
753
+ this.appendSidebarItem(fileKey, "n1", data.message)
754
+ this.stats.n1++
755
+ this.updateSummaryStats()
756
+ break
757
+
758
+ case "coverage_update":
759
+ this.setCoverageLabel(fileKey, data.pct)
760
+ this.stats.coverageSum += data.pct
761
+ this.stats.coverageCount++
762
+ this.updateSummaryStats()
763
+ break
764
+
765
+ case "file_complete":
766
+ this.finalizePanel(fileKey, data)
767
+ this.subscriptions[fileKey]?.unsubscribe()
768
+ delete this.subscriptions[fileKey]
769
+ break
770
+
771
+ case "file_error":
772
+ this.setFileDot(fileKey, "error")
773
+ this.appendTerminalLine(fileKey, `ERROR: ${data.message}`, "error")
774
+ break
775
+ }
776
+ }
777
+
778
+ // ── Panel construction ────────────────────────────────────────────────────
779
+
780
+ createFilePanel(fileKey, displayName, runId) {
781
+ const panel = document.createElement("div")
782
+ panel.id = `dm-file-${fileKey}`
783
+ panel.className = "dm-file-row"
784
+ panel.innerHTML = this.panelTemplate(fileKey, displayName, runId)
785
+ this.fileListTarget.appendChild(panel)
786
+ // Auto-open the first panel
787
+ if (this.fileListTarget.children.length === 1) {
788
+ this.togglePanel(fileKey)
789
+ }
790
+ }
791
+
792
+ panelTemplate(fileKey, displayName, runId) {
793
+ return `
794
+ <div class="dm-file-header" data-action="click->metrics#togglePanel"
795
+ data-file-key="${fileKey}">
796
+ <span class="dm-chevron" id="dm-chev-${fileKey}">▶</span>
797
+ <span class="dm-dot dm-dot--pending" id="dm-dot-${fileKey}"></span>
798
+ <span class="dm-file-name">${displayName}</span>
799
+ <span class="dm-file-meta" id="dm-meta-${fileKey}"></span>
800
+ </div>
801
+
802
+ <div class="dm-progress-bar">
803
+ <div class="dm-progress-fill" id="dm-prog-${fileKey}" style="width: 0%"></div>
804
+ </div>
805
+
806
+ <div class="dm-panel" id="dm-panel-${fileKey}">
807
+ <div class="dm-terminal" id="dm-term-${fileKey}"></div>
808
+ <div class="dm-sidebar">
809
+ <div class="dm-sidebar-section">
810
+ <div class="dm-sidebar-label">Slow queries</div>
811
+ <div id="dm-slow-${fileKey}"></div>
812
+ </div>
813
+ <div class="dm-sidebar-section">
814
+ <div class="dm-sidebar-label">N+1 issues</div>
815
+ <div id="dm-n1-${fileKey}"></div>
816
+ </div>
817
+ <div class="dm-sidebar-section">
818
+ <div class="dm-sidebar-label">Coverage</div>
819
+ <div id="dm-cov-${fileKey}" class="dm-sidebar-cov">—</div>
820
+ </div>
821
+ <div class="dm-sidebar-section">
822
+ <a href="${this.logDownloadUrl(runId, fileKey)}"
823
+ class="dm-log-link" id="dm-log-${fileKey}" style="display:none">
824
+ Download log
825
+ </a>
826
+ </div>
827
+ </div>
828
+ </div>
829
+ `
830
+ }
831
+
832
+ togglePanel(fileKey) {
833
+ // Called by data-action click or programmatically
834
+ if (typeof fileKey !== "string") {
835
+ // Called from data-action — extract from dataset
836
+ fileKey = fileKey.currentTarget.dataset.fileKey
837
+ }
838
+ const panel = document.getElementById(`dm-panel-${fileKey}`)
839
+ const chev = document.getElementById(`dm-chev-${fileKey}`)
840
+ const open = panel.classList.toggle("dm-panel--open")
841
+ chev.classList.toggle("dm-chevron--open", open)
842
+ }
843
+
844
+ // ── Panel update helpers ──────────────────────────────────────────────────
845
+
846
+ appendTerminalLine(fileKey, text, eventType = "info") {
847
+ const term = document.getElementById(`dm-term-${fileKey}`)
848
+ if (!term) return
849
+ const line = document.createElement("div")
850
+ line.className = `dm-term-line dm-term-line--${eventType}`
851
+ line.textContent = text
852
+ term.appendChild(line)
853
+ term.scrollTop = term.scrollHeight
854
+
855
+ this.stats.tests += (eventType === "pass" || eventType === "fail") ? 1 : 0
856
+ this.updateSummaryStats()
857
+ }
858
+
859
+ appendSidebarItem(fileKey, type, text) {
860
+ const container = document.getElementById(`dm-${type}-${fileKey}`)
861
+ if (!container) return
862
+ const item = document.createElement("div")
863
+ item.className = `dm-sidebar-item dm-sidebar-item--${type}`
864
+ item.textContent = text
865
+ container.appendChild(item)
866
+ }
867
+
868
+ advanceProgress(fileKey) {
869
+ // Optimistic progress: each completed test line moves the bar
870
+ // We don't know total count upfront, so we use a sqrt curve that
871
+ // approaches 95% and snaps to 100% on file_complete
872
+ const fill = document.getElementById(`dm-prog-${fileKey}`)
873
+ if (!fill) return
874
+ const current = parseFloat(fill.style.width) || 0
875
+ const next = Math.min(95, current + (95 - current) * 0.12)
876
+ fill.style.width = `${next.toFixed(1)}%`
877
+ }
878
+
879
+ setFileDot(fileKey, state) {
880
+ const dot = document.getElementById(`dm-dot-${fileKey}`)
881
+ if (!dot) return
882
+ dot.className = `dm-dot dm-dot--${state}`
883
+ }
884
+
885
+ setCoverageLabel(fileKey, pct) {
886
+ const el = document.getElementById(`dm-cov-${fileKey}`)
887
+ if (el) el.textContent = `${pct}%`
888
+ }
889
+
890
+ finalizePanel(fileKey, data) {
891
+ const status = data.status // "passed" | "failed"
892
+ this.setFileDot(fileKey, status)
893
+
894
+ const fill = document.getElementById(`dm-prog-${fileKey}`)
895
+ if (fill) {
896
+ fill.style.width = "100%"
897
+ fill.classList.toggle("dm-progress-fill--passed", status === "passed")
898
+ fill.classList.toggle("dm-progress-fill--failed", status === "failed")
899
+ }
900
+
901
+ const meta = document.getElementById(`dm-meta-${fileKey}`)
902
+ if (meta) {
903
+ meta.innerHTML = this.metaBadges(data)
904
+ }
905
+
906
+ const logLink = document.getElementById(`dm-log-${fileKey}`)
907
+ if (logLink) logLink.style.display = "block"
908
+ }
909
+
910
+ metaBadges(data) {
911
+ const secs = ((data.duration_ms || 0) / 1000).toFixed(2)
912
+ let html = `<span class="dm-badge dm-badge--time">${secs}s</span>`
913
+ if (data.n1_count > 0) html += `<span class="dm-badge dm-badge--n1">${data.n1_count} N+1</span>`
914
+ if (data.slow_count > 0) html += `<span class="dm-badge dm-badge--slow">${data.slow_count} slow</span>`
915
+ if (data.coverage != null) html += `<span class="dm-badge dm-badge--cov">${data.coverage}% cov</span>`
916
+ return html
917
+ }
918
+
919
+ // ── Summary stats ─────────────────────────────────────────────────────────
920
+
921
+ updateSummaryStats() {
922
+ if (this.hasStatTotalTestsTarget)
923
+ this.statTotalTestsTarget.textContent = this.stats.tests
924
+
925
+ if (this.hasStatSlowQueriesTarget)
926
+ this.statSlowQueriesTarget.textContent = this.stats.slow
927
+
928
+ if (this.hasStatN1IssuesTarget)
929
+ this.statN1IssuesTarget.textContent = this.stats.n1
930
+
931
+ if (this.hasStatCoverageTarget && this.stats.coverageCount > 0) {
932
+ const avg = (this.stats.coverageSum / this.stats.coverageCount).toFixed(1)
933
+ this.statCoverageTarget.textContent = `${avg}%`
934
+ }
935
+ }
936
+
937
+ // ── Teardown & utilities ──────────────────────────────────────────────────
938
+
939
+ resetState() {
940
+ this.teardownSubscriptions()
941
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 }
942
+ this.fileListTarget.innerHTML = ""
943
+ this.updateSummaryStats()
944
+ }
945
+
946
+ teardownSubscriptions() {
947
+ Object.values(this.subscriptions).forEach(sub => sub?.unsubscribe())
948
+ this.subscriptions = {}
949
+ this.runSubscription?.unsubscribe()
950
+ this.runSubscription = null
951
+ }
952
+
953
+ get runTestsUrl() {
954
+ return `${window.location.origin}${this.element.closest("[data-devmetrics-mount]")
955
+ ?.dataset.devmetricsMountPath || "/devmetrics"}/run_tests`
956
+ }
957
+
958
+ logDownloadUrl(runId, fileKey) {
959
+ return `/devmetrics/runs/${runId}/logs/${fileKey}/download`
960
+ }
961
+
962
+ get csrfToken() {
963
+ return document.querySelector("meta[name='csrf-token']")?.content || ""
964
+ }
965
+ }
966
+ ```
967
+
968
+ ### Step 5.3 — ActionCable consumer
969
+
970
+ **`app/javascript/devmetrics/channels/consumer.js`**
971
+
972
+ ```javascript
973
+ import { createConsumer } from "@rails/actioncable"
974
+ export default createConsumer()
975
+ ```
976
+
977
+ ---
978
+
979
+ ## Phase 6 — CSS
980
+
981
+ **`app/assets/stylesheets/devmetrics/dashboard.css`**
982
+
983
+ Minimal additions on top of your existing styles:
984
+
985
+ ```css
986
+ /* File list */
987
+ .dm-file-row {
988
+ border-top: 1px solid #e5e7eb;
989
+ }
990
+
991
+ .dm-file-header {
992
+ display: flex;
993
+ align-items: center;
994
+ gap: 10px;
995
+ padding: 9px 16px;
996
+ cursor: pointer;
997
+ user-select: none;
998
+ }
999
+
1000
+ .dm-file-header:hover {
1001
+ background: #f9fafb;
1002
+ }
1003
+
1004
+ /* Status dots */
1005
+ .dm-dot {
1006
+ width: 8px;
1007
+ height: 8px;
1008
+ border-radius: 50%;
1009
+ flex-shrink: 0;
1010
+ }
1011
+ .dm-dot--pending { background: #d1d5db; }
1012
+ .dm-dot--running { background: #f59e0b; animation: dm-pulse 1s infinite; }
1013
+ .dm-dot--passed { background: #10b981; }
1014
+ .dm-dot--failed { background: #ef4444; }
1015
+ .dm-dot--error { background: #ef4444; }
1016
+
1017
+ @keyframes dm-pulse {
1018
+ 0%, 100% { opacity: 1; }
1019
+ 50% { opacity: 0.3; }
1020
+ }
1021
+
1022
+ /* Chevron */
1023
+ .dm-chevron { font-size: 11px; color: #9ca3af; transition: transform 0.15s; }
1024
+ .dm-chevron--open { transform: rotate(90deg); }
1025
+
1026
+ /* Progress bar */
1027
+ .dm-progress-bar {
1028
+ height: 2px;
1029
+ background: #f3f4f6;
1030
+ }
1031
+ .dm-progress-fill {
1032
+ height: 100%;
1033
+ background: #f59e0b;
1034
+ transition: width 0.25s ease-out;
1035
+ }
1036
+ .dm-progress-fill--passed { background: #10b981; }
1037
+ .dm-progress-fill--failed { background: #ef4444; }
1038
+
1039
+ /* Panel (collapsed by default) */
1040
+ .dm-panel {
1041
+ display: none;
1042
+ border-top: 1px solid #e5e7eb;
1043
+ }
1044
+ .dm-panel--open {
1045
+ display: flex;
1046
+ }
1047
+
1048
+ /* Terminal pane */
1049
+ .dm-terminal {
1050
+ flex: 1;
1051
+ font-family: "SF Mono", "Fira Code", monospace;
1052
+ font-size: 11px;
1053
+ line-height: 1.65;
1054
+ padding: 10px 14px;
1055
+ max-height: 240px;
1056
+ overflow-y: auto;
1057
+ background: #0f172a;
1058
+ color: #94a3b8;
1059
+ }
1060
+ .dm-term-line--pass { color: #34d399; }
1061
+ .dm-term-line--fail { color: #f87171; }
1062
+ .dm-term-line--error { color: #f87171; }
1063
+ .dm-term-line--pending { color: #fbbf24; }
1064
+ .dm-term-line--summary { color: #e2e8f0; font-weight: 600; }
1065
+
1066
+ /* Sidebar */
1067
+ .dm-sidebar {
1068
+ width: 220px;
1069
+ border-left: 1px solid #1e293b;
1070
+ background: #0f172a;
1071
+ padding: 10px 12px;
1072
+ font-size: 11px;
1073
+ display: flex;
1074
+ flex-direction: column;
1075
+ gap: 12px;
1076
+ }
1077
+ .dm-sidebar-label {
1078
+ color: #475569;
1079
+ font-size: 10px;
1080
+ text-transform: uppercase;
1081
+ letter-spacing: 0.06em;
1082
+ margin-bottom: 4px;
1083
+ }
1084
+ .dm-sidebar-item {
1085
+ color: #94a3b8;
1086
+ padding: 2px 0;
1087
+ line-height: 1.5;
1088
+ word-break: break-word;
1089
+ }
1090
+ .dm-sidebar-item--slow { color: #fbbf24; }
1091
+ .dm-sidebar-item--n1 { color: #f87171; }
1092
+ .dm-sidebar-cov { color: #34d399; font-size: 14px; font-weight: 600; }
1093
+
1094
+ .dm-log-link {
1095
+ color: #60a5fa;
1096
+ font-size: 11px;
1097
+ text-decoration: none;
1098
+ }
1099
+ .dm-log-link:hover { text-decoration: underline; }
1100
+
1101
+ /* Badges */
1102
+ .dm-badge {
1103
+ font-size: 11px;
1104
+ padding: 2px 6px;
1105
+ border-radius: 10px;
1106
+ margin-left: 4px;
1107
+ }
1108
+ .dm-badge--time { background: #f3f4f6; color: #6b7280; }
1109
+ .dm-badge--n1 { background: #fee2e2; color: #dc2626; }
1110
+ .dm-badge--slow { background: #fef3c7; color: #d97706; }
1111
+ .dm-badge--cov { background: #d1fae5; color: #065f46; }
1112
+ ```
1113
+
1114
+ ---
1115
+
1116
+ ## Phase 7 — Solid Cable & Queue Config
1117
+
1118
+ ### Step 7.1 — Solid Cable (already installed if following README)
1119
+
1120
+ Ensure `config/cable.yml`:
1121
+
1122
+ ```yaml
1123
+ development:
1124
+ adapter: solid_cable
1125
+ polling_interval: 0.1.seconds
1126
+ message_retention: 1.day
1127
+
1128
+ production:
1129
+ adapter: solid_cable
1130
+ polling_interval: 0.5.seconds
1131
+ message_retention: 1.week
1132
+ ```
1133
+
1134
+ ### Step 7.2 — ActiveJob concurrency for parallel jobs
1135
+
1136
+ For Solid Queue (Rails 8 / recommended):
1137
+
1138
+ ```yaml
1139
+ # config/queue.yml (Solid Queue)
1140
+ default: &default
1141
+ workers:
1142
+ - queues: [devmetrics]
1143
+ threads: 10 # allow up to 10 parallel file jobs
1144
+ processes: 1
1145
+
1146
+ development:
1147
+ <<: *default
1148
+
1149
+ production:
1150
+ <<: *default
1151
+ ```
1152
+
1153
+ For Sidekiq, add to `config/sidekiq.yml`:
1154
+
1155
+ ```yaml
1156
+ :queues:
1157
+ - [devmetrics, 5]
1158
+ :concurrency: 10
1159
+ ```
1160
+
1161
+ ---
1162
+
1163
+ ## Phase 8 — Generator Updates
1164
+
1165
+ Update `lib/devmetrics/generators/install_generator.rb` to also:
1166
+
1167
+ 1. Copy the two new migrations
1168
+ 2. Add `require "devmetrics/models/run"` and `require "devmetrics/models/file_result"` to the engine autoload
1169
+ 3. Print post-install notice about queue concurrency requirement
1170
+
1171
+ ---
1172
+
1173
+ ## File Checklist
1174
+
1175
+ | File | Action |
1176
+ |------|--------|
1177
+ | `db/migrate/..._create_devmetrics_runs.rb` | Create |
1178
+ | `db/migrate/..._create_devmetrics_file_results.rb` | Create |
1179
+ | `lib/devmetrics/models/run.rb` | Create |
1180
+ | `lib/devmetrics/models/file_result.rb` | Create |
1181
+ | `lib/devmetrics/log_writer.rb` | Create |
1182
+ | `lib/devmetrics/sql_instrumentor.rb` | Create (replace inline subscriber) |
1183
+ | `lib/devmetrics/run_orchestrator.rb` | Create |
1184
+ | `lib/devmetrics/jobs/file_runner_job.rb` | Create (replaces `performance_test_runner_job.rb`) |
1185
+ | `lib/devmetrics/engine.rb` | Modify — move SQL subscriber here, add model autoloads |
1186
+ | `app/channels/devmetrics/metrics_channel.rb` | Modify — add stream_type routing |
1187
+ | `app/controllers/devmetrics/metrics_controller.rb` | Modify — update `run_tests`, add `run_status`, `download_log` |
1188
+ | `config/routes.rb` (engine) | Modify — add new routes |
1189
+ | `app/views/devmetrics/metrics/index.html.erb` | Modify — add data targets |
1190
+ | `app/javascript/devmetrics/controllers/metrics_controller.js` | Rewrite |
1191
+ | `app/javascript/devmetrics/channels/consumer.js` | Create |
1192
+ | `app/assets/stylesheets/devmetrics/dashboard.css` | Modify — add panel/terminal/sidebar styles |
1193
+
1194
+ ---
1195
+
1196
+ ## Implementation Order for Claude Code
1197
+
1198
+ Run these phases in sequence. Each phase is independently testable.
1199
+
1200
+ 1. **Phase 1** — migrations + models → `rails db:migrate` → verify tables exist
1201
+ 2. **Phase 2** — `LogWriter`, `SqlInstrumentor`, `RunOrchestrator`, `FileRunnerJob` → unit test each class
1202
+ 3. **Phase 3** — `MetricsChannel` update → test via `rails console`: `ActionCable.server.broadcast(...)`
1203
+ 4. **Phase 4** — controller + routes → `curl -X POST /devmetrics/run_tests` → verify JSON response + job enqueued
1204
+ 5. **Phase 5** — Stimulus controller + consumer.js → open `/devmetrics`, run tests, watch panels appear
1205
+ 6. **Phase 6** — CSS → visual polish
1206
+ 7. **Phase 7** — queue concurrency config → verify multiple jobs actually run in parallel (`bin/jobs` or Sidekiq UI)
1207
+ 8. **Phase 8** — generator updates → test fresh install on a blank Rails app
1208
+
1209
+ ---
1210
+
1211
+ ## Testing
1212
+
1213
+ ```bash
1214
+ # Unit tests
1215
+ bundle exec rspec spec/lib/devmetrics/run_orchestrator_spec.rb
1216
+ bundle exec rspec spec/lib/devmetrics/jobs/file_runner_job_spec.rb
1217
+ bundle exec rspec spec/lib/devmetrics/sql_instrumentor_spec.rb
1218
+
1219
+ # Integration: start server + run via curl
1220
+ bin/dev &
1221
+ curl -s -X POST http://localhost:3000/devmetrics/run_tests \
1222
+ -H "Content-Type: application/json" | jq .
1223
+
1224
+ # Check logs were written
1225
+ ls -la log/devmetrics/runs/
1226
+
1227
+ # Check DB records
1228
+ rails runner "puts ::Devmetrics::Run.last.inspect"
1229
+ rails runner "puts ::Devmetrics::FileResult.all.map(&:status).inspect"
1230
+ ```
1231
+
1232
+ ---
1233
+
1234
+ ## Known Gotchas
1235
+
1236
+ **Thread isolation for SQL**: `Thread.current[THREAD_KEY]` works correctly with Puma and Solid Queue (each job runs in its own thread). With Falcon (fiber-based), replace with a Fiber-local key using `Fiber[:devmetrics_sql_collector]`.
1237
+
1238
+ **SimpleCov interference**: If the host app already runs SimpleCov, calling it inside a subprocess is fine — subprocesses have isolated Ruby state. The host app's coverage is unaffected.
1239
+
1240
+ **Open3 + bundler**: Always run `bundle exec rspec` inside `chdir: Rails.root` so the gem's own Gemfile doesn't shadow the host app's.
1241
+
1242
+ **Progress bar**: Because we don't know total test count before the run, the progress bar uses an asymptotic curve (each step covers 12% of remaining distance, capping at 95%). It snaps to 100% on `file_complete`. If you want an accurate bar, add `--dry-run` before the real run to get the count, but this doubles wall time.
1243
+
1244
+ **Log rotation**: `log/devmetrics/runs/` is not cleaned up automatically. Add a rake task `devmetrics:clean_logs[days=7]` (not in scope of this plan) or rely on `logrotate`.