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,2 @@
1
+ module MetricsHelper
2
+ end
@@ -0,0 +1,2 @@
1
+ module PlaygroundHelper
2
+ end
@@ -0,0 +1,2 @@
1
+ import { createConsumer } from "@rails/actioncable"
2
+ export default createConsumer("/cable")
@@ -0,0 +1 @@
1
+ import "devformance/channels/consumer"
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = true
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.element.textContent = "Hello World!"
6
+ }
7
+ }
@@ -0,0 +1,14 @@
1
+ // This file is auto-generated by ./bin/rails stimulus:manifest:update
2
+ // Run that command whenever you add a new controller or create them with
3
+ // ./bin/rails generate stimulus controllerName
4
+
5
+ import { application } from "devformance/controllers/application"
6
+
7
+ import HelloController from "devformance/controllers/hello_controller"
8
+ application.register("hello", HelloController)
9
+
10
+ import MetricsController from "devformance/controllers/metrics_controller"
11
+ application.register("metrics", MetricsController)
12
+
13
+ import PlaygroundController from "devformance/controllers/playground_controller"
14
+ application.register("playground", PlaygroundController)
@@ -0,0 +1,364 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import consumer from "devformance/channels/consumer"
3
+
4
+ export default class extends Controller {
5
+ static targets = [
6
+ "runBtn", "fileList",
7
+ "statTotalTests", "statSlowQueries", "statN1Issues", "statCoverage"
8
+ ]
9
+
10
+ static values = { cableUrl: String }
11
+
12
+ connect() {
13
+ this.subscriptions = {}
14
+ this.runSubscription = null
15
+ this.fileMeta = {}
16
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 }
17
+ this.currentRunId = null
18
+ }
19
+
20
+ disconnect() {
21
+ this.teardownSubscriptions()
22
+ }
23
+
24
+ // ── Run trigger ──────────────────────────────────────────────────────────
25
+
26
+ async runTests() {
27
+ this.runBtnTarget.disabled = true
28
+ this.runBtnTarget.textContent = "Starting…"
29
+ this.resetState()
30
+
31
+ try {
32
+ const resp = await fetch(this.runTestsUrl, {
33
+ method: "POST",
34
+ headers: { "X-CSRF-Token": this.csrfToken, "Content-Type": "application/json" }
35
+ })
36
+ const data = await resp.json()
37
+ if (!resp.ok) throw new Error(data.error || "Failed to start run")
38
+
39
+ this.currentRunId = data.run_id
40
+ this.runBtnTarget.textContent = `Running (${data.files.length} files)…`
41
+
42
+ this.subscribeToRun(data.run_id)
43
+ data.files.forEach(f => {
44
+ this.fileMeta[f.file_key] = f
45
+ this.createFilePanel(f.file_key, f.display_name, data.run_id)
46
+ this.subscribeToFile(f.file_key, data.run_id)
47
+ })
48
+
49
+ } catch (err) {
50
+ this.runBtnTarget.disabled = false
51
+ this.runBtnTarget.textContent = "Run performance tests"
52
+ console.error("Devformance run error:", err)
53
+ }
54
+ }
55
+
56
+ // ── Subscriptions ─────────────────────────────────────────────────────────
57
+
58
+ subscribeToRun(runId) {
59
+ this.runSubscription = consumer.subscriptions.create(
60
+ { channel: "Devformance::MetricsChannel", stream_type: "run", run_id: runId },
61
+ { received: (data) => this.handleRunEvent(data) }
62
+ )
63
+ }
64
+
65
+ handleRunEvent(data) {
66
+ if (data.type === "run_complete") {
67
+ this.runBtnTarget.disabled = false
68
+ this.runBtnTarget.textContent = "Run performance tests"
69
+ this.runSubscription?.unsubscribe()
70
+ }
71
+ }
72
+
73
+ subscribeToFile(fileKey, runId) {
74
+ const sub = consumer.subscriptions.create(
75
+ { channel: "Devformance::MetricsChannel", stream_type: "file", file_key: fileKey, run_id: runId },
76
+ { received: (data) => this.handleFileEvent(fileKey, data) }
77
+ )
78
+ this.subscriptions[fileKey] = sub
79
+ }
80
+
81
+ handleFileEvent(fileKey, data) {
82
+ switch (data.type) {
83
+
84
+ case "file_started": {
85
+ this.setFileDot(fileKey, "running")
86
+ const loader = document.getElementById(`dm-loader-${fileKey}`)
87
+ if (loader) loader.style.display = "inline-flex"
88
+ const meta = this.fileMeta[fileKey]
89
+ const path = meta?.display_name || fileKey
90
+ this.appendTerminalLine(fileKey, `$ bundle exec rspec ${path} --format documentation`, "command")
91
+ break
92
+ }
93
+
94
+ case "test_output":
95
+ this.appendTerminalLine(fileKey, data.line, data.event_type)
96
+ if (data.event_type === "pass" || data.event_type === "fail") this.advanceProgress(fileKey)
97
+ break
98
+
99
+ case "slow_query": {
100
+ const text = `${data.query.sql} (${data.query.ms}ms)`
101
+ this.appendTerminalLine(fileKey, `SLOW: ${text}`, "slow")
102
+ this.appendSidebarItem(fileKey, "slow", text)
103
+ this.stats.slow++
104
+ this.updateSummaryStats()
105
+ break
106
+ }
107
+
108
+ case "n1_detected":
109
+ this.appendTerminalLine(fileKey, `N+1 detected: ${data.message}`, "n1")
110
+ this.appendSidebarItem(fileKey, "n1", data.message)
111
+ this.stats.n1++
112
+ this.updateSummaryStats()
113
+ break
114
+
115
+ case "coverage_update":
116
+ this.setCoverageLabel(fileKey, data.pct)
117
+ this.stats.coverageSum += data.pct
118
+ this.stats.coverageCount++
119
+ this.updateSummaryStats()
120
+ break
121
+
122
+ case "file_complete": {
123
+ const loader2 = document.getElementById(`dm-loader-${fileKey}`)
124
+ if (loader2) loader2.style.display = "none"
125
+ this.finalizePanel(fileKey, data)
126
+ this.subscriptions[fileKey]?.unsubscribe()
127
+ delete this.subscriptions[fileKey]
128
+ break
129
+ }
130
+
131
+ case "file_error":
132
+ this.setFileDot(fileKey, "error")
133
+ this.appendTerminalLine(fileKey, `ERROR: ${data.message}`, "error")
134
+ break
135
+ }
136
+ }
137
+
138
+ // ── Panel construction ────────────────────────────────────────────────────
139
+
140
+ createFilePanel(fileKey, displayName, runId) {
141
+ const row = document.createElement("div")
142
+ row.id = `dm-file-${fileKey}`
143
+ row.className = "dm-file-row"
144
+ row.innerHTML = this.panelTemplate(fileKey, displayName, runId)
145
+ this.fileListTarget.appendChild(row)
146
+ // Auto-open the first panel
147
+ if (this.fileListTarget.children.length === 1) this.togglePanel(fileKey)
148
+ }
149
+
150
+ panelTemplate(fileKey, displayName, runId) {
151
+ return `
152
+ <div class="dm-file-header" data-action="click->metrics#togglePanel" data-file-key="${fileKey}">
153
+ <span class="dm-chevron" id="dm-chev-${fileKey}">▶</span>
154
+ <span class="dm-dot dm-dot--pending" id="dm-dot-${fileKey}"></span>
155
+ <span class="dm-file-name">${displayName}</span>
156
+ <span class="dm-file-meta" id="dm-meta-${fileKey}"></span>
157
+ <span class="dm-file-loader" id="dm-loader-${fileKey}" style="display:none">
158
+ <span class="dm-spinner dm-spinner--xs"></span>
159
+ </span>
160
+ </div>
161
+
162
+ <div class="dm-progress-bar">
163
+ <div class="dm-progress-fill" id="dm-prog-${fileKey}" style="width:0%"></div>
164
+ </div>
165
+
166
+ <div class="dm-panel" id="dm-panel-${fileKey}">
167
+ <div class="dm-terminal" id="dm-term-${fileKey}"></div>
168
+ <div class="dm-sidebar">
169
+
170
+ <div class="dm-sidebar-stat">
171
+ <div class="dm-sidebar-stat-hd">
172
+ <svg class="dm-sidebar-icon dm-sidebar-icon--slow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2 4 9h4.5L6.5 14 12 7H7.5L9.5 2Z"/></svg>
173
+ <span class="dm-sidebar-label">Slow Queries</span>
174
+ <span class="dm-sidebar-value dm-sidebar-value--slow" id="dm-slow-count-${fileKey}">0</span>
175
+ </div>
176
+ <div id="dm-slow-${fileKey}" class="dm-sidebar-items"></div>
177
+ </div>
178
+
179
+ <div class="dm-sidebar-stat">
180
+ <div class="dm-sidebar-stat-hd">
181
+ <svg class="dm-sidebar-icon dm-sidebar-icon--n1" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2 2 13h12L8 2Z"/><line x1="8" y1="7.5" x2="8" y2="9.5"/><circle cx="8" cy="11.5" r="0.6" fill="currentColor" stroke="none"/></svg>
182
+ <span class="dm-sidebar-label">N+1 Issues</span>
183
+ <span class="dm-sidebar-value dm-sidebar-value--n1" id="dm-n1-count-${fileKey}">0</span>
184
+ </div>
185
+ <div id="dm-n1-${fileKey}" class="dm-sidebar-items"></div>
186
+ </div>
187
+
188
+ <div class="dm-sidebar-stat">
189
+ <div class="dm-sidebar-stat-hd">
190
+ <svg class="dm-sidebar-icon dm-sidebar-icon--cov" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2 3 4v4c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4L8 2Z"/><polyline points="5.5,8 7.5,10 10.5,7"/></svg>
191
+ <span class="dm-sidebar-label">Coverage</span>
192
+ <span class="dm-sidebar-value dm-sidebar-value--cov" id="dm-cov-${fileKey}">—</span>
193
+ </div>
194
+ </div>
195
+
196
+ <div class="dm-sidebar-log">
197
+ <a href="${this.logDownloadUrl(runId, fileKey)}" class="dm-log-link" id="dm-log-${fileKey}" style="display:none">
198
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width:11px;height:11px;flex-shrink:0"><path d="M8 2v8M5 7l3 3 3-3"/><path d="M3 12h10"/></svg>
199
+ Download log
200
+ </a>
201
+ </div>
202
+
203
+ </div>
204
+ </div>
205
+ `
206
+ }
207
+
208
+ togglePanel(fileKey) {
209
+ if (typeof fileKey !== "string") fileKey = fileKey.currentTarget.dataset.fileKey
210
+ const panel = document.getElementById(`dm-panel-${fileKey}`)
211
+ const chev = document.getElementById(`dm-chev-${fileKey}`)
212
+ const open = panel.classList.toggle("dm-panel--open")
213
+ chev.classList.toggle("dm-chevron--open", open)
214
+ }
215
+
216
+ // ── Terminal helpers ──────────────────────────────────────────────────────
217
+
218
+ appendTerminalLine(fileKey, rawText, eventType = "info") {
219
+ const term = document.getElementById(`dm-term-${fileKey}`)
220
+ if (!term) return
221
+
222
+ // Skip blank info lines that just add noise
223
+ if (eventType === "info" && rawText.trim() === "") return
224
+
225
+ const text = this.formatLine(rawText, eventType)
226
+ const line = document.createElement("div")
227
+ line.className = `dm-term-line dm-term-line--${eventType}`
228
+ line.textContent = text
229
+ term.appendChild(line)
230
+ term.scrollTop = term.scrollHeight
231
+
232
+ this.stats.tests += (eventType === "pass" || eventType === "fail") ? 1 : 0
233
+ this.updateSummaryStats()
234
+ }
235
+
236
+ formatLine(text, eventType) {
237
+ if (eventType === "pass") return "✓ " + text.replace(/^\s*[\.·✓]\s*/, "").trim()
238
+ if (eventType === "fail") return "✗ " + text.replace(/^\s*[\.·✗]\s*/, "").trim()
239
+ return text
240
+ }
241
+
242
+ appendSidebarItem(fileKey, type, text) {
243
+ const container = document.getElementById(`dm-${type}-${fileKey}`)
244
+ if (!container) return
245
+ const item = document.createElement("div")
246
+ item.className = `dm-sidebar-item dm-sidebar-item--${type}`
247
+ item.textContent = text
248
+ container.appendChild(item)
249
+
250
+ const counter = document.getElementById(`dm-${type}-count-${fileKey}`)
251
+ if (counter) counter.textContent = container.children.length
252
+ }
253
+
254
+ advanceProgress(fileKey) {
255
+ const fill = document.getElementById(`dm-prog-${fileKey}`)
256
+ if (!fill) return
257
+ const cur = parseFloat(fill.style.width) || 0
258
+ fill.style.width = `${Math.min(95, cur + (95 - cur) * 0.12).toFixed(1)}%`
259
+ }
260
+
261
+ setFileDot(fileKey, state) {
262
+ const dot = document.getElementById(`dm-dot-${fileKey}`)
263
+ if (dot) dot.className = `dm-dot dm-dot--${state}`
264
+ }
265
+
266
+ setCoverageLabel(fileKey, pct) {
267
+ const el = document.getElementById(`dm-cov-${fileKey}`)
268
+ if (el) el.textContent = `${pct}%`
269
+ }
270
+
271
+ finalizePanel(fileKey, data) {
272
+ const status = data.status
273
+ this.setFileDot(fileKey, status)
274
+
275
+ // Progress bar → 100%
276
+ const fill = document.getElementById(`dm-prog-${fileKey}`)
277
+ if (fill) {
278
+ fill.style.width = "100%"
279
+ fill.classList.toggle("dm-progress-fill--passed", status === "passed")
280
+ fill.classList.toggle("dm-progress-fill--failed", status === "failed")
281
+ }
282
+
283
+ // Summary line in terminal
284
+ const term = document.getElementById(`dm-term-${fileKey}`)
285
+ if (term) {
286
+ const sep = document.createElement("div")
287
+ sep.className = "dm-term-separator"
288
+ term.appendChild(sep)
289
+ const total = (data.passed || 0) + (data.failed || 0)
290
+ const covStr = data.coverage != null ? ` — coverage ${data.coverage}%` : ""
291
+ this.appendTerminalLine(
292
+ fileKey,
293
+ `${total} example${total !== 1 ? "s" : ""}. ${data.failed || 0} failure${data.failed !== 1 ? "s" : ""}${covStr}`,
294
+ "summary"
295
+ )
296
+ }
297
+
298
+ // Header badges
299
+ const meta = document.getElementById(`dm-meta-${fileKey}`)
300
+ if (meta) meta.innerHTML = this.metaBadges(data)
301
+
302
+ // Log link
303
+ const logLink = document.getElementById(`dm-log-${fileKey}`)
304
+ if (logLink) logLink.style.display = "inline-flex"
305
+ }
306
+
307
+ metaBadges(data) {
308
+ const secs = ((data.duration_ms || 0) / 1000).toFixed(2)
309
+ let html = ''
310
+ if ((data.n1_count || 0) > 0)
311
+ html += `<span class="dm-badge dm-badge--n1" title="N+1 issues">${data.n1_count} N+1</span>`
312
+ if ((data.slow_count || 0) > 0)
313
+ html += `<span class="dm-badge dm-badge--slow" title="Slow queries">${data.slow_count} slow</span>`
314
+ if (data.coverage != null)
315
+ html += `<span class="dm-badge dm-badge--cov" title="Coverage">${data.coverage}%</span>`
316
+ html += `<span class="dm-badge dm-badge--time" title="Duration">${secs}s</span>`
317
+ return html
318
+ }
319
+
320
+ // ── Summary stats ─────────────────────────────────────────────────────────
321
+
322
+ updateSummaryStats() {
323
+ if (this.hasStatTotalTestsTarget)
324
+ this.statTotalTestsTarget.textContent = this.stats.tests
325
+ if (this.hasStatSlowQueriesTarget)
326
+ this.statSlowQueriesTarget.textContent = this.stats.slow
327
+ if (this.hasStatN1IssuesTarget)
328
+ this.statN1IssuesTarget.textContent = this.stats.n1
329
+ if (this.hasStatCoverageTarget && this.stats.coverageCount > 0)
330
+ this.statCoverageTarget.textContent =
331
+ `${(this.stats.coverageSum / this.stats.coverageCount).toFixed(1)}%`
332
+ }
333
+
334
+ // ── Teardown & utilities ──────────────────────────────────────────────────
335
+
336
+ resetState() {
337
+ this.teardownSubscriptions()
338
+ this.fileMeta = {}
339
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 }
340
+ this.fileListTarget.innerHTML = ""
341
+ this.updateSummaryStats()
342
+ }
343
+
344
+ teardownSubscriptions() {
345
+ Object.values(this.subscriptions).forEach(s => s?.unsubscribe())
346
+ this.subscriptions = {}
347
+ this.runSubscription?.unsubscribe()
348
+ this.runSubscription = null
349
+ }
350
+
351
+ get runTestsUrl() {
352
+ const mount = this.element.closest("[data-devformance-mount-path]")
353
+ ?.dataset.devformanceMountPath || "/devformance"
354
+ return `${mount}/run_tests`
355
+ }
356
+
357
+ logDownloadUrl(runId, fileKey) {
358
+ return `/devformance/runs/${runId}/logs/${fileKey}/download`
359
+ }
360
+
361
+ get csrfToken() {
362
+ return document.querySelector("meta[name='csrf-token']")?.content || ""
363
+ }
364
+ }
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["code", "output", "runBtn"]
5
+
6
+ connect() {
7
+ console.log("Playground controller connected")
8
+ }
9
+
10
+ async run() {
11
+ if (!this.hasRunBtnTarget) return
12
+ this.runBtnTarget.disabled = true
13
+
14
+ try {
15
+ const resp = await fetch("/devformance/playground/run", {
16
+ method: "POST",
17
+ headers: {
18
+ "Content-Type": "application/json",
19
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content || ""
20
+ },
21
+ body: JSON.stringify({ code: this.hasCodeTarget ? this.codeTarget.value : "" })
22
+ })
23
+ const data = await resp.json()
24
+ if (this.hasOutputTarget) {
25
+ this.outputTarget.textContent = JSON.stringify(data, null, 2)
26
+ }
27
+ } catch (err) {
28
+ console.error("Playground error:", err)
29
+ } finally {
30
+ if (this.hasRunBtnTarget) this.runBtnTarget.disabled = false
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,4 @@
1
+ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
2
+ import "@hotwired/turbo-rails"
3
+ import "devformance/controllers"
4
+ import "devformance/channels"
@@ -0,0 +1,7 @@
1
+ class ApplicationJob < ActiveJob::Base
2
+ # Automatically retry jobs that encountered a deadlock
3
+ # retry_on ActiveRecord::Deadlocked
4
+
5
+ # Most jobs are safe to ignore if the underlying records are no longer available
6
+ # discard_on ActiveJob::DeserializationError
7
+ end