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,856 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <title><%= content_for(:title) || "Devformance" %></title>
5
+ <meta name="description" content="Universal test performance monitoring — slow queries, N+1 detection, coverage, live real-time updates for any test framework.">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="mobile-web-app-capable" content="yes">
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+ <meta name="action-cable-url" content="/cable">
12
+
13
+ <%= yield :head %>
14
+
15
+ <link rel="icon" href="<%= icon_svg_path %>" type="image/svg+xml">
16
+ <link rel="apple-touch-icon" href="<%= icon_png_path %>">
17
+
18
+ <%# Google Fonts: IBM Plex Sans + JetBrains Mono %>
19
+ <link rel="preconnect" href="https://fonts.googleapis.com">
20
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
22
+
23
+ <%# Load JS from jsdelivr CDN (UMD builds for global access) %>
24
+ <script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.12/dist/turbo.es2017-umd.min.js"></script>
25
+ <script src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.umd.min.js"></script>
26
+ <script src="https://cdn.jsdelivr.net/npm/actioncable@5.2.8-1/lib/assets/compiled/action_cable.js"></script>
27
+ <script>
28
+ (function() {
29
+ if (typeof Stimulus === "undefined" || !Stimulus.Application) return;
30
+ if (typeof ActionCable === "undefined") return;
31
+
32
+ let usePolling = false;
33
+ let pollingInterval = null;
34
+ let modeIndicator = null;
35
+ let consumer = null;
36
+
37
+ function showModeIndicator(text, isRealtime) {
38
+ if (modeIndicator) modeIndicator.remove();
39
+ modeIndicator = document.createElement('div');
40
+ modeIndicator.className = 'dm-mode-indicator' + (isRealtime ? ' dm-mode-indicator--realtime' : '');
41
+ modeIndicator.innerHTML = `
42
+ <span class="dm-mode-dot"></span>
43
+ <span>${text}</span>
44
+ ${!isRealtime ? '<button class="dm-retry-btn" onclick="window.retryWebSocket()">Retry</button>' : ''}
45
+ `;
46
+ document.body.appendChild(modeIndicator);
47
+ }
48
+
49
+ function hideModeIndicator() {
50
+ if (modeIndicator) {
51
+ modeIndicator.remove();
52
+ modeIndicator = null;
53
+ }
54
+ }
55
+
56
+ window.retryWebSocket = function() {
57
+ usePolling = false;
58
+ if (pollingInterval) clearInterval(pollingInterval);
59
+ if (consumer) consumer.disconnect();
60
+ consumer = ActionCable.createConsumer();
61
+ if (window.devformanceApp && window.devformanceApp.currentRunId) {
62
+ window.devformanceApp.subscribeToRun(window.devformanceApp.currentRunId);
63
+ }
64
+ showModeIndicator('Connecting...', true);
65
+ setTimeout(() => {
66
+ if (!usePolling) showModeIndicator('Using Solid Cable', true);
67
+ }, 2000);
68
+ };
69
+
70
+ const app = Stimulus.Application.start();
71
+
72
+ app.register("metrics", class extends Stimulus.Controller {
73
+ static targets = ["runBtn", "fileList", "statTotalTests", "statSlowQueries", "statN1Issues", "statCoverage"];
74
+
75
+ connect() {
76
+ this.subscriptions = {};
77
+ this.runSubscription = null;
78
+ this.fileMeta = {};
79
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 };
80
+ this.currentRunId = null;
81
+ this.frameworkName = null;
82
+ this.runnerCommand = null;
83
+ window.devformanceApp = this;
84
+ this.initConnection();
85
+ }
86
+
87
+ disconnect() {
88
+ this.teardownSubscriptions();
89
+ if (pollingInterval) clearInterval(pollingInterval);
90
+ if (consumer) consumer.disconnect();
91
+ }
92
+
93
+ initConnection() {
94
+ if (typeof ActionCable === "undefined") {
95
+ this.enablePollingMode();
96
+ return;
97
+ }
98
+ try {
99
+ consumer = ActionCable.createConsumer();
100
+ showModeIndicator('Connecting...', true);
101
+ const timeout = setTimeout(() => {
102
+ if (usePolling === false) {
103
+ this.enablePollingMode();
104
+ }
105
+ }, 3000);
106
+ this.testConnection = () => {
107
+ clearTimeout(timeout);
108
+ showModeIndicator('Using Solid Cable', true);
109
+ };
110
+ setTimeout(() => this.testConnection(), 1000);
111
+ } catch (e) {
112
+ this.enablePollingMode();
113
+ }
114
+ }
115
+
116
+ enablePollingMode() {
117
+ if (usePolling) return;
118
+ usePolling = true;
119
+ showModeIndicator('Using Polling mode', false);
120
+ }
121
+
122
+ async pollStatus() {
123
+ if (!usePolling || !this.currentRunId) return;
124
+ try {
125
+ const mount = this.element.closest("[data-devformance-mount-path]")
126
+ ?.dataset.devformanceMountPath || "/devformance";
127
+ const resp = await fetch(`${mount}/runs/${this.currentRunId}/status`);
128
+ const data = await resp.json();
129
+ this.updateFromStatus(data);
130
+ if (data.status === 'running') {
131
+ pollingInterval = setTimeout(() => this.pollStatus(), 1000);
132
+ }
133
+ } catch (err) {
134
+ console.error('Polling error:', err);
135
+ }
136
+ }
137
+
138
+ updateFromStatus(data) {
139
+ if (data.files) {
140
+ data.files.forEach(f => {
141
+ if (f.status === 'passed' || f.status === 'failed') {
142
+ this.setFileDot(f.file_key, f.status);
143
+ this.setFileLoader(f.file_key, false);
144
+ this.updateFileStats(f.file_key, f);
145
+ }
146
+ });
147
+ }
148
+ if (data.status === 'completed' || data.status === 'failed') {
149
+ this.runBtnTarget.disabled = false;
150
+ this.runBtnTarget.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5,3 19,12 5,21"/></svg> Run performance tests`;
151
+ }
152
+ }
153
+
154
+ updateFileStats(fileKey, data) {
155
+ const slowEl = document.getElementById(`dm-slow-${fileKey}`);
156
+ const n1El = document.getElementById(`dm-n1-${fileKey}`);
157
+ const covEl = document.getElementById(`dm-cov-${fileKey}`);
158
+ if (slowEl) slowEl.innerText = data.slow_query_count || 0;
159
+ if (n1El) n1El.innerText = data.n1_count || 0;
160
+ if (covEl) covEl.innerText = data.coverage != null ? `${data.coverage}%` : '—';
161
+ }
162
+
163
+ async runTests() {
164
+ this.runBtnTarget.disabled = true;
165
+ this.runBtnTarget.innerHTML = `<span class="dm-spinner dm-spinner--sm"></span> Starting…`;
166
+
167
+ if (pollingInterval) {
168
+ clearInterval(pollingInterval);
169
+ pollingInterval = null;
170
+ }
171
+ this.teardownSubscriptions();
172
+
173
+ if (this.fileListTarget) {
174
+ this.fileListTarget.innerHTML = "";
175
+ }
176
+
177
+ this.fileMeta = {};
178
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 };
179
+ this.currentRunId = null;
180
+ this.updateSummaryStats();
181
+
182
+ await Promise.resolve();
183
+
184
+ try {
185
+ const mount = this.element.closest("[data-devformance-mount-path]")
186
+ ?.dataset.devformanceMountPath || "/devformance";
187
+ const resp = await fetch(`${mount}/run_tests`, {
188
+ method: "POST",
189
+ headers: {
190
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content || "",
191
+ "Content-Type": "application/json"
192
+ }
193
+ });
194
+ const data = await resp.json();
195
+ if (!resp.ok) throw new Error(data.error || "Failed to start run");
196
+ this.currentRunId = data.run_id;
197
+ this.runBtnTarget.innerHTML = `<span class="dm-spinner dm-spinner--sm"></span> Running (${data.files.length} files)`;
198
+ data.files.forEach(f => {
199
+ this.fileMeta[f.file_key] = f;
200
+ this.createFilePanel(f.file_key, f.display_name, data.run_id);
201
+ });
202
+ if (usePolling) {
203
+ pollingInterval = setTimeout(() => this.pollStatus(), 1000);
204
+ } else {
205
+ this.subscribeToRun(data.run_id);
206
+ data.files.forEach(f => this.subscribeToFile(f.file_key, data.run_id));
207
+ }
208
+ } catch (err) {
209
+ this.runBtnTarget.disabled = false;
210
+ this.runBtnTarget.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5,3 19,12 5,21"/></svg> Run performance tests`;
211
+ console.error("Devformance run error:", err);
212
+ }
213
+ }
214
+
215
+ subscribeToRun(runId) {
216
+ this.runSubscription = consumer.subscriptions.create(
217
+ { channel: "DevformanceChannel", stream_type: "run", run_id: runId },
218
+ { received: (data) => this.handleRunEvent(data) }
219
+ );
220
+ }
221
+
222
+ handleRunEvent(data) {
223
+ if (data.type === "run_started") {
224
+ this.frameworkName = data.framework || null;
225
+ }
226
+ if (data.type === "run_complete") {
227
+ this.runBtnTarget.disabled = false;
228
+ this.runBtnTarget.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5,3 19,12 5,21"/></svg> Run performance tests`;
229
+ hideModeIndicator();
230
+ if (this.runSubscription) this.runSubscription.unsubscribe();
231
+ }
232
+ }
233
+
234
+ subscribeToFile(fileKey, runId) {
235
+ const sub = consumer.subscriptions.create(
236
+ { channel: "DevformanceChannel", stream_type: "file", file_key: fileKey, run_id: runId },
237
+ { received: (data) => this.handleFileEvent(fileKey, data) }
238
+ );
239
+ this.subscriptions[fileKey] = sub;
240
+ }
241
+
242
+ handleFileEvent(fileKey, data) {
243
+ switch (data.type) {
244
+ case "file_started": {
245
+ this.setFileDot(fileKey, "running");
246
+ this.setFileLoader(fileKey, true);
247
+ const meta = this.fileMeta[fileKey];
248
+ const path = meta && meta.display_name ? meta.display_name : fileKey;
249
+ const framework = data.framework || this.frameworkName || "RSpec";
250
+ const cmd = framework === "Minitest" ? `bundle exec rails test ${path} -vc` : `bundle exec rspec ${path} --format documentation`;
251
+ this.appendTerminalLine(fileKey, `$ ${cmd}`, "command");
252
+ break;
253
+ }
254
+ case "test_output":
255
+ this.appendTerminalLine(fileKey, data.line, data.event_type);
256
+ if (data.event_type === "pass" || data.event_type === "fail") this.advanceProgress(fileKey);
257
+ break;
258
+ case "slow_query": {
259
+ const text = `${data.query.sql} (${data.query.ms}ms)`;
260
+ this.appendTerminalLine(fileKey, `SLOW: ${text}`, "slow");
261
+ this.incrementSidebarValue(fileKey, "slow");
262
+ this.stats.slow++;
263
+ this.updateSummaryStats();
264
+ break;
265
+ }
266
+ case "n1_detected":
267
+ this.appendTerminalLine(fileKey, `N+1 detected: ${data.message}`, "n1");
268
+ this.incrementSidebarValue(fileKey, "n1");
269
+ this.stats.n1++;
270
+ this.updateSummaryStats();
271
+ break;
272
+ case "coverage_update":
273
+ this.setCoverageLabelWithHighlight(fileKey, data.pct);
274
+ this.stats.coverageSum += data.pct;
275
+ this.stats.coverageCount++;
276
+ this.updateSummaryStats();
277
+ break;
278
+ case "file_complete":
279
+ this.finalizePanel(fileKey, data);
280
+ if (this.subscriptions[fileKey]) this.subscriptions[fileKey].unsubscribe();
281
+ delete this.subscriptions[fileKey];
282
+ break;
283
+ case "file_error":
284
+ this.setFileDot(fileKey, "error");
285
+ this.appendTerminalLine(fileKey, `ERROR: ${data.message}`, "error");
286
+ break;
287
+ }
288
+ }
289
+
290
+ createFilePanel(fileKey, displayName, runId) {
291
+ const row = document.createElement("div");
292
+ row.id = `dm-file-${fileKey}`;
293
+ row.className = "dm-file-row";
294
+ row.innerHTML = this.panelTemplate(fileKey, displayName, runId);
295
+ this.fileListTarget.appendChild(row);
296
+ if (this.fileListTarget.children.length === 1) this.togglePanel(fileKey);
297
+ }
298
+
299
+ panelTemplate(fileKey, displayName, runId) {
300
+ return `
301
+ <div class="dm-file-header" data-action="click->metrics#togglePanel" data-file-key="${fileKey}">
302
+ <span class="dm-chevron" id="dm-chev-${fileKey}">
303
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
304
+ </span>
305
+ <span class="dm-dot dm-dot--pending" id="dm-dot-${fileKey}"></span>
306
+ <span class="dm-file-name">${displayName}</span>
307
+ <span class="dm-file-meta" id="dm-meta-${fileKey}"></span>
308
+ <span class="dm-file-loader" id="dm-loader-${fileKey}">
309
+ <span class="dm-spinner dm-spinner--xs"></span>
310
+ </span>
311
+ </div>
312
+ <div class="dm-progress-bar">
313
+ <div class="dm-progress-fill" id="dm-prog-${fileKey}" style="width:0%"></div>
314
+ </div>
315
+ <div class="dm-panel" id="dm-panel-${fileKey}">
316
+ <div class="dm-terminal" id="dm-term-${fileKey}"></div>
317
+ <div class="dm-sidebar">
318
+ <div class="dm-sidebar-stats">
319
+ <div class="dm-sidebar-stat">
320
+ <div class="dm-sidebar-icon dm-sidebar-icon--slow">
321
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
322
+ </div>
323
+ <div class="dm-sidebar-content">
324
+ <span class="dm-sidebar-value" id="dm-slow-${fileKey}">0</span>
325
+ <span class="dm-sidebar-label">Slow</span>
326
+ </div>
327
+ </div>
328
+ <div class="dm-sidebar-stat">
329
+ <div class="dm-sidebar-icon dm-sidebar-icon--n1">
330
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z"/><path d="M9 9h6v6H9z"/><path d="M14 14h2v2h-2z"/></svg>
331
+ </div>
332
+ <div class="dm-sidebar-content">
333
+ <span class="dm-sidebar-value" id="dm-n1-${fileKey}">0</span>
334
+ <span class="dm-sidebar-label">N+1</span>
335
+ </div>
336
+ </div>
337
+ <div class="dm-sidebar-stat">
338
+ <div class="dm-sidebar-icon dm-sidebar-icon--cov">
339
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
340
+ </div>
341
+ <div class="dm-sidebar-content">
342
+ <span class="dm-sidebar-value dm-sidebar-value--cov" id="dm-cov-${fileKey}">0%</span>
343
+ <span class="dm-sidebar-label">Coverage</span>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ <div class="dm-sidebar-footer">
348
+ <a href="/devformance/runs/${runId}/logs/${fileKey}/download" class="dm-log-link" id="dm-log-${fileKey}">
349
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
350
+ Download log
351
+ </a>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ `;
356
+ }
357
+
358
+ togglePanel(fileKey) {
359
+ if (typeof fileKey !== "string") fileKey = fileKey.currentTarget.dataset.fileKey;
360
+ const panel = document.getElementById(`dm-panel-${fileKey}`);
361
+ const chev = document.getElementById(`dm-chev-${fileKey}`);
362
+ const header = document.querySelector(`#dm-file-${fileKey} .dm-file-header`);
363
+ const open = panel.classList.toggle("dm-panel--open");
364
+ chev.classList.toggle("dm-chevron--open", open);
365
+ header.classList.toggle("dm-file-header--open", open);
366
+ }
367
+
368
+ appendTerminalLine(fileKey, rawText, eventType) {
369
+ if (!eventType) eventType = "info";
370
+ const term = document.getElementById(`dm-term-${fileKey}`);
371
+ if (!term) return;
372
+ if (eventType === "info" && rawText.trim() === "") return;
373
+ const text = this.formatLine(rawText, eventType);
374
+ const line = document.createElement("div");
375
+ line.className = `dm-term-line dm-term-line--${eventType}`;
376
+ line.textContent = text;
377
+ term.appendChild(line);
378
+ term.scrollTop = term.scrollHeight;
379
+ this.stats.tests += (eventType === "pass" || eventType === "fail") ? 1 : 0;
380
+ this.updateSummaryStats();
381
+ }
382
+
383
+ formatLine(text, eventType) {
384
+ if (eventType === "pass") return "✓ " + text.replace(/^\s*[\.·✓]\s*/, "").trim();
385
+ // if (eventType === "fail") return "✗ " + text.replace(/^\s*[F!✗]\s*/, "").trim();
386
+ // if (eventType === "fail") return "✗ " + text.replace(/^\s*[\.·✗]\s*/, "").trim();
387
+ return text;
388
+ }
389
+
390
+ appendSidebarItem(fileKey, type, text) {
391
+ const container = document.getElementById(`dm-${type}-${fileKey}`);
392
+ if (!container) return;
393
+ const item = document.createElement("div");
394
+ item.className = `dm-sidebar-item dm-sidebar-item--${type}`;
395
+ item.textContent = text;
396
+ container.appendChild(item);
397
+ }
398
+
399
+ incrementSidebarValue(fileKey, type) {
400
+ const el = document.getElementById(`dm-${type}-${fileKey}`);
401
+ if (!el) return;
402
+ const current = parseInt(el.textContent) || 0;
403
+ const newVal = current + 1;
404
+ el.textContent = newVal;
405
+ el.classList.add(`dm-sidebar-value--highlight`, `dm-sidebar-value--${type}`);
406
+ if (newVal > 0) el.classList.add(`dm-sidebar-value--active`);
407
+ setTimeout(() => el.classList.remove(`dm-sidebar-value--highlight`), 300);
408
+ }
409
+
410
+ advanceProgress(fileKey) {
411
+ const fill = document.getElementById(`dm-prog-${fileKey}`);
412
+ if (!fill) return;
413
+ const cur = parseFloat(fill.style.width) || 0;
414
+ fill.style.width = `${Math.min(95, cur + (95 - cur) * 0.12).toFixed(1)}%`;
415
+ }
416
+
417
+ setFileDot(fileKey, state) {
418
+ const dot = document.getElementById(`dm-dot-${fileKey}`);
419
+ if (dot) dot.className = `dm-dot dm-dot--${state}`;
420
+ }
421
+
422
+ setFileLoader(fileKey, show) {
423
+ const loader = document.getElementById(`dm-loader-${fileKey}`);
424
+ if (loader) loader.style.display = show ? 'inline-flex' : 'none';
425
+ }
426
+
427
+ setCoverageLabelWithHighlight(fileKey, pct) {
428
+ const el = document.getElementById(`dm-cov-${fileKey}`);
429
+ if (el) {
430
+ el.textContent = `${pct}%`;
431
+ el.classList.add(`dm-sidebar-value--highlight`, `dm-sidebar-value--cov`);
432
+ setTimeout(() => el.classList.remove(`dm-sidebar-value--highlight`), 300);
433
+ }
434
+ }
435
+
436
+ finalizePanel(fileKey, data) {
437
+ const status = data.status;
438
+ this.setFileDot(fileKey, status);
439
+ this.setFileLoader(fileKey, false);
440
+ const fill = document.getElementById(`dm-prog-${fileKey}`);
441
+ if (fill) {
442
+ fill.style.width = "100%";
443
+ fill.classList.toggle("dm-progress-fill--passed", status === "passed");
444
+ fill.classList.toggle("dm-progress-fill--failed", status === "failed");
445
+ }
446
+ const term = document.getElementById(`dm-term-${fileKey}`);
447
+ if (term) {
448
+ const sep = document.createElement("div");
449
+ sep.className = "dm-term-separator";
450
+ term.appendChild(sep);
451
+ const total = (data.passed || 0) + (data.failed || 0);
452
+ const covStr = data.coverage != null ? ` — coverage ${data.coverage}%` : "";
453
+ const framework = this.frameworkName || "RSpec";
454
+ const testLabel = framework === "Minitest" ? "test" : "example";
455
+ this.appendTerminalLine(
456
+ fileKey,
457
+ `${total} ${testLabel}${total !== 1 ? "s" : ""}. ${data.failed || 0} failure${data.failed !== 1 ? "s" : ""}${covStr}`,
458
+ "summary"
459
+ );
460
+ }
461
+ const meta = document.getElementById(`dm-meta-${fileKey}`);
462
+ if (meta) meta.innerHTML = this.metaBadges(data);
463
+ const logLink = document.getElementById(`dm-log-${fileKey}`);
464
+ if (logLink) logLink.style.display = "inline-flex";
465
+ this.setSidebarFinalValues(fileKey, data);
466
+ this.updateSummaryFromFile(data);
467
+ }
468
+
469
+ setSidebarFinalValues(fileKey, data) {
470
+ const slowEl = document.getElementById(`dm-slow-${fileKey}`);
471
+ const n1El = document.getElementById(`dm-n1-${fileKey}`);
472
+ const covEl = document.getElementById(`dm-cov-${fileKey}`);
473
+ if (slowEl) slowEl.textContent = data.slow_count || 0;
474
+ if (n1El) n1El.textContent = data.n1_count || 0;
475
+ if (covEl) covEl.textContent = data.coverage != null ? `${data.coverage}%` : "—%";
476
+ }
477
+
478
+ updateSummaryFromFile(data) {
479
+ if (data.slow_count != null) this.stats.slow += data.slow_count;
480
+ if (data.n1_count != null) this.stats.n1 += data.n1_count;
481
+ if (data.coverage != null) {
482
+ this.stats.coverageSum += data.coverage;
483
+ this.stats.coverageCount++;
484
+ }
485
+ this.updateSummaryStats();
486
+ }
487
+
488
+ metaBadges(data) {
489
+ const secs = ((data.duration_ms || 0) / 1000).toFixed(2);
490
+ const n1Count = data.n1_count || 0;
491
+ const slowCount = data.slow_count || 0;
492
+ const coverage = data.coverage;
493
+
494
+ return `
495
+ <div class="dm-meta-badges">
496
+ ${n1Count > 0 ? `
497
+ <span class="dm-meta-badge dm-meta-badge--n1" title="N+1 issues detected">
498
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z"/><path d="M9 9h6v6H9z"/></svg>
499
+ <span>${n1Count}</span>
500
+ </span>
501
+ ` : ''}
502
+ ${slowCount > 0 ? `
503
+ <span class="dm-meta-badge dm-meta-badge--slow" title="Slow queries detected">
504
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
505
+ <span>${slowCount}</span>
506
+ </span>
507
+ ` : ''}
508
+ ${coverage != null ? `
509
+ <span class="dm-meta-badge dm-meta-badge--cov" title="Code coverage">
510
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
511
+ <span>${coverage}%</span>
512
+ </span>
513
+ ` : ''}
514
+ <span class="dm-meta-badge dm-meta-badge--time" title="Test duration">
515
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
516
+ <span>${secs}s</span>
517
+ </span>
518
+ </div>
519
+ `;
520
+ }
521
+
522
+ updateSummaryStats() {
523
+ if (this.hasStatTotalTestsTarget)
524
+ this.statTotalTestsTarget.textContent = this.stats.tests;
525
+ if (this.hasStatSlowQueriesTarget)
526
+ this.statSlowQueriesTarget.textContent = this.stats.slow;
527
+ if (this.hasStatN1IssuesTarget)
528
+ this.statN1IssuesTarget.textContent = this.stats.n1;
529
+ if (this.hasStatCoverageTarget && this.stats.coverageCount > 0)
530
+ this.statCoverageTarget.textContent =
531
+ `${(this.stats.coverageSum / this.stats.coverageCount).toFixed(1)}%`;
532
+ }
533
+
534
+ resetState() {
535
+ this.teardownSubscriptions();
536
+ this.fileMeta = {};
537
+ this.stats = { tests: 0, slow: 0, n1: 0, coverageSum: 0, coverageCount: 0 };
538
+ if (this.fileListTarget) {
539
+ this.fileListTarget.innerHTML = "";
540
+ }
541
+ this.updateSummaryStats();
542
+ }
543
+
544
+ teardownSubscriptions() {
545
+ Object.values(this.subscriptions).forEach(function(s) { if (s) s.unsubscribe(); });
546
+ this.subscriptions = {};
547
+ if (this.runSubscription) this.runSubscription.unsubscribe();
548
+ this.runSubscription = null;
549
+ }
550
+ });
551
+ })();
552
+ </script>
553
+ <%# Dashboard panel styles - inlined for engine compatibility %>
554
+ <style>
555
+ .dm-file-row{border-bottom:1px solid rgba(255,255,255,.06)}.dm-file-row:last-child{border-bottom:none}.dm-file-header{display:flex;align-items:center;gap:10px;padding:12px 16px;cursor:pointer;user-select:none;transition:background .15s ease,border-color .15s ease;min-height:48px;border-left:3px solid transparent}.dm-file-header:hover{background:rgba(255,255,255,.03)}.dm-file-header--open{border-left-color:#22C55E;background:rgba(34,197,94,.04)}.dm-chevron{color:#4b5563;width:16px;height:16px;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:transform .2s ease,color .15s ease}.dm-file-header:hover .dm-chevron{color:#94a3b8}.dm-chevron--open{transform:rotate(90deg);color:#22C55E}.dm-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.dm-dot--pending{background:#2d3748;border:1px solid #4a5568}.dm-dot--running{background:#f59e0b;animation:dm-pulse 1.2s ease infinite}.dm-dot--passed{background:#10b981}.dm-dot--failed,.dm-dot--error{background:#ef4444}@keyframes dm-pulse{0%,100%{opacity:1}50%{opacity:.3}}.dm-file-name{flex:1;font-family:"JetBrains Mono","SF Mono","Fira Code",monospace;font-size:12px;color:#cbd5e1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dm-file-meta{display:flex;align-items:center;gap:6px;flex-shrink:0}.dm-file-loader{display:inline-flex;align-items:center;gap:4px;color:#64748b;font-size:10px}.dm-progress-bar{height:2px;background:rgba(255,255,255,.04)}.dm-progress-fill{height:100%;background:#f59e0b;transition:width .4s ease-out}.dm-progress-fill--passed{background:#10b981}.dm-progress-fill--failed{background:#ef4444}.dm-panel{display:none;border-top:1px solid rgba(255,255,255,.06);height:280px;overflow:hidden}.dm-panel--open{display:flex}.dm-terminal{flex:1;min-width:0;padding:12px 16px;overflow-y:auto;background:#080d14;font-family:"JetBrains Mono","SF Mono","Fira Code",monospace;font-size:11px;line-height:1.7}.dm-terminal::-webkit-scrollbar{width:4px}.dm-terminal::-webkit-scrollbar-track{background:0 0}.dm-terminal::-webkit-scrollbar-thumb{background:#2d3748;border-radius:2px}.dm-term-line{white-space:pre-wrap;word-break:break-all}.dm-term-line--command{color:#4b5563;font-size:10px;margin:8px 0 4px}.dm-term-line--pass{color:#4ade80}.dm-term-line--fail,.dm-term-line--error{color:#f87171}.dm-term-line--pending,.dm-term-line--slow{color:#fbbf24}.dm-term-line--info{color:#94a3b8}.dm-term-line--summary{color:#e2e8f0;font-weight:600;padding-top:8px}.dm-term-line--cov{color:#34d399;font-weight:600}.dm-sidebar{width:180px;border-left:1px solid rgba(255,255,255,.06);background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,.05) 100%);display:flex;flex-direction:column}.dm-sidebar-stats{display:flex;flex-direction:column;gap:4px;padding:12px}.dm-sidebar-stat{display:flex;align-items:center;gap:10px;padding:10px;background:rgba(255,255,255,.03);border-radius:8px;border:1px solid rgba(255,255,255,.04);transition:background .15s ease,border-color .15s ease}.dm-sidebar-stat:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.08)}.dm-sidebar-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.dm-sidebar-icon--slow{background:rgba(251,191,36,.12);color:#fbbf24}.dm-sidebar-icon--n1{background:rgba(239,68,68,.12);color:#f87171}.dm-sidebar-icon--cov{background:rgba(52,211,153,.12);color:#34d399}.dm-sidebar-content{display:flex;flex-direction:column;gap:1px}.dm-sidebar-value{font-size:18px;font-weight:700;font-family:"JetBrains Mono","SF Mono","Fira Code",monospace;color:#e2e8f0;line-height:1}.dm-sidebar-value--highlight{animation:dm-val-pulse .3s ease}.dm-sidebar-value--slow{color:#fbbf24}.dm-sidebar-value--n1{color:#f87171}.dm-sidebar-value--cov{color:#34d399}.dm-sidebar-label{font-size:9px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;color:#64748b}.dm-sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.04);margin-top:auto}.dm-log-link{display:flex;align-items:center;gap:6px;font-size:10px;color:#64748b;text-decoration:none;transition:color .15s ease,background .15s ease,border-color .15s ease;padding:8px 10px;border-radius:6px;background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.04);width:100%}.dm-log-link:hover{color:#94a3b8;background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.08)}@keyframes dm-val-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.15)}}.dm-spinner{display:inline-block;border:2px solid rgba(255,255,255,.15);border-top-color:#22C55E;border-radius:50%;animation:dm-spin .7s linear infinite}.dm-spinner--xs{width:10px;height:10px;border-width:1.5px}.dm-spinner--sm{width:14px;height:14px;border-width:2px}.dm-spinner--md{width:18px;height:18px;border-width:2px}@keyframes dm-spin{to{transform:rotate(360deg)}}.dm-meta-badges{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}.dm-meta-badge{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:6px;font-size:10px;font-weight:600;font-family:"JetBrains Mono","SF Mono","Fira Code",monospace;white-space:nowrap;transition:transform .15s ease,box-shadow .15s ease}.dm-meta-badge:hover{transform:translateY(-1px)}.dm-meta-badge--n1{background:rgba(239,68,68,.12);color:#f87171;border:1px solid rgba(239,68,68,.2)}.dm-meta-badge--n1:hover{box-shadow:0 2px 8px rgba(239,68,68,.2)}.dm-meta-badge--slow{background:rgba(251,191,36,.12);color:#fbbf24;border:1px solid rgba(251,191,36,.2)}.dm-meta-badge--slow:hover{box-shadow:0 2px 8px rgba(251,191,36,.2)}.dm-meta-badge--cov{background:rgba(52,211,153,.12);color:#34d399;border:1px solid rgba(52,211,153,.2)}.dm-meta-badge--cov:hover{box-shadow:0 2px 8px rgba(52,211,153,.2)}.dm-meta-badge--time{background:rgba(100,116,139,.15);color:#94a3b8;border:1px solid rgba(100,116,139,.2)}
556
+ </style>
557
+
558
+ <style>
559
+ :root {
560
+ --bg-base: #0F172A;
561
+ --bg-card: #1E293B;
562
+ --bg-card-hover: #243348;
563
+ --border: #334155;
564
+ --border-subtle: #1E293B;
565
+ --text-primary: #F8FAFC;
566
+ --text-secondary: #94A3B8;
567
+ --text-muted: #64748B;
568
+ --green: #22C55E;
569
+ --green-dim: #166534;
570
+ --red: #EF4444;
571
+ --red-dim: #7F1D1D;
572
+ --blue: #3B82F6;
573
+ --purple: #A855F7;
574
+ --yellow: #EAB308;
575
+ --font-ui: 'IBM Plex Sans', system-ui, sans-serif;
576
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
577
+ }
578
+
579
+ *, *::before, *::after { box-sizing: border-box; }
580
+
581
+ html, body {
582
+ background-color: var(--bg-base);
583
+ color: var(--text-primary);
584
+ font-family: var(--font-ui);
585
+ min-height: 100vh;
586
+ -webkit-font-smoothing: antialiased;
587
+ }
588
+
589
+ /* Scrollbar */
590
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
591
+ ::-webkit-scrollbar-track { background: var(--bg-base); }
592
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
593
+ ::-webkit-scrollbar-thumb:hover { background: #475569; }
594
+
595
+ /* Subtle grid overlay on body */
596
+ body::before {
597
+ content: '';
598
+ position: fixed;
599
+ inset: 0;
600
+ background-image:
601
+ linear-gradient(rgba(51,65,85,0.25) 1px, transparent 1px),
602
+ linear-gradient(90deg, rgba(51,65,85,0.25) 1px, transparent 1px);
603
+ background-size: 40px 40px;
604
+ pointer-events: none;
605
+ z-index: 0;
606
+ }
607
+
608
+ body > * { position: relative; z-index: 1; }
609
+
610
+ /* Stat card hover */
611
+ .stat-card {
612
+ background: var(--bg-card);
613
+ border: 1px solid var(--border);
614
+ border-radius: 12px;
615
+ padding: 1.5rem;
616
+ transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
617
+ cursor: default;
618
+ }
619
+ .stat-card:hover {
620
+ border-color: #475569;
621
+ background: var(--bg-card-hover);
622
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
623
+ }
624
+
625
+ /* Panel */
626
+ .panel {
627
+ background: var(--bg-card);
628
+ border: 1px solid var(--border);
629
+ border-radius: 12px;
630
+ overflow: hidden;
631
+ }
632
+ .panel-header {
633
+ padding: 1.25rem 1.5rem;
634
+ border-bottom: 1px solid var(--border);
635
+ display: flex;
636
+ align-items: center;
637
+ justify-content: space-between;
638
+ gap: 0.75rem;
639
+ }
640
+
641
+ /* Badge */
642
+ .badge {
643
+ display: inline-flex;
644
+ align-items: center;
645
+ gap: 0.25rem;
646
+ padding: 0.125rem 0.625rem;
647
+ border-radius: 9999px;
648
+ font-size: 0.7rem;
649
+ font-weight: 600;
650
+ letter-spacing: 0.05em;
651
+ text-transform: uppercase;
652
+ }
653
+ .badge-red { background: rgba(239,68,68,0.15); color: #FCA5A5; border: 1px solid rgba(239,68,68,0.25); }
654
+ .badge-green{ background: rgba(34,197,94,0.12); color: #86EFAC; border: 1px solid rgba(34,197,94,0.2); }
655
+ .badge-blue { background: rgba(59,130,246,0.12); color: #93C5FD; border: 1px solid rgba(59,130,246,0.2); }
656
+ .badge-muted{ background: rgba(100,116,139,0.15); color: #94A3B8; border: 1px solid rgba(100,116,139,0.2); }
657
+
658
+ /* Mono stat number */
659
+ .mono { font-family: var(--font-mono); }
660
+
661
+ /* Progress bar track */
662
+ .progress-track {
663
+ background: rgba(51,65,85,0.6);
664
+ border-radius: 9999px;
665
+ height: 6px;
666
+ overflow: hidden;
667
+ }
668
+ .progress-bar {
669
+ height: 100%;
670
+ border-radius: 9999px;
671
+ transition: width 1s ease-out;
672
+ position: relative;
673
+ }
674
+ .progress-bar::after {
675
+ content: '';
676
+ position: absolute;
677
+ inset: 0;
678
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
679
+ animation: shimmer 2.5s infinite;
680
+ }
681
+ @keyframes shimmer {
682
+ 0% { transform: translateX(-100%); }
683
+ 100% { transform: translateX(100%); }
684
+ }
685
+
686
+ /* Live pulse dot */
687
+ .pulse-dot {
688
+ width: 8px; height: 8px;
689
+ border-radius: 50%;
690
+ background: var(--green);
691
+ box-shadow: 0 0 0 0 rgba(34,197,94,0.5);
692
+ animation: pulse-ring 2s ease-out infinite;
693
+ flex-shrink: 0;
694
+ }
695
+ @keyframes pulse-ring {
696
+ 0% { box-shadow: 0 0 0 0 rgba(34,197,94,0.5); }
697
+ 70% { box-shadow: 0 0 0 8px rgba(34,197,94,0); }
698
+ 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
699
+ }
700
+
701
+ /* Nav */
702
+ .site-nav {
703
+ position: sticky;
704
+ top: 0;
705
+ z-index: 50;
706
+ background: rgba(15,23,42,0.85);
707
+ backdrop-filter: blur(12px);
708
+ -webkit-backdrop-filter: blur(12px);
709
+ border-bottom: 1px solid var(--border);
710
+ }
711
+
712
+ /* CTA button */
713
+ .btn-primary {
714
+ display: inline-flex;
715
+ align-items: center;
716
+ gap: 0.5rem;
717
+ padding: 0.5rem 1.125rem;
718
+ border-radius: 8px;
719
+ font-size: 0.875rem;
720
+ font-weight: 600;
721
+ background: var(--green);
722
+ color: #022c22;
723
+ border: 1px solid rgba(34,197,94,0.5);
724
+ cursor: pointer;
725
+ transition: background-color 0.2s, box-shadow 0.2s, transform 0.15s;
726
+ text-decoration: none;
727
+ }
728
+ .btn-primary:hover {
729
+ background: #16a34a;
730
+ box-shadow: 0 0 16px rgba(34,197,94,0.35);
731
+ transform: translateY(-1px);
732
+ }
733
+ .btn-primary:active { transform: translateY(0); }
734
+
735
+ /* Slow query item */
736
+ .slow-query-item {
737
+ background: rgba(239,68,68,0.06);
738
+ border: 1px solid rgba(239,68,68,0.2);
739
+ border-radius: 8px;
740
+ padding: 1rem;
741
+ transition: border-color 0.2s, background-color 0.2s;
742
+ }
743
+ .slow-query-item:hover {
744
+ border-color: rgba(239,68,68,0.4);
745
+ background: rgba(239,68,68,0.1);
746
+ }
747
+
748
+ /* Fade-in animation for new items */
749
+ @keyframes fadeSlideIn {
750
+ from { opacity: 0; transform: translateY(-8px); }
751
+ to { opacity: 1; transform: translateY(0); }
752
+ }
753
+ .fade-in { animation: fadeSlideIn 0.4s ease-out forwards; }
754
+
755
+ /* Mode Indicator */
756
+ .dm-mode-indicator {
757
+ position: fixed;
758
+ bottom: 24px;
759
+ right: 24px;
760
+ z-index: 1000;
761
+ display: flex;
762
+ align-items: center;
763
+ gap: 10px;
764
+ padding: 10px 16px;
765
+ background: rgba(30, 41, 59, 0.95);
766
+ backdrop-filter: blur(12px);
767
+ -webkit-backdrop-filter: blur(12px);
768
+ border: 1px solid rgba(255, 255, 255, 0.1);
769
+ border-radius: 12px;
770
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
771
+ font-family: 'IBM Plex Sans', system-ui, sans-serif;
772
+ font-size: 13px;
773
+ font-weight: 500;
774
+ color: #e2e8f0;
775
+ animation: dm-mode-enter 0.3s ease-out;
776
+ }
777
+ @keyframes dm-mode-enter {
778
+ from { opacity: 0; transform: translateY(10px) scale(0.95); }
779
+ to { opacity: 1; transform: translateY(0) scale(1); }
780
+ }
781
+ .dm-mode-indicator--realtime {
782
+ border-color: rgba(34, 197, 94, 0.3);
783
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(34, 197, 94, 0.1);
784
+ }
785
+ .dm-mode-dot {
786
+ width: 8px;
787
+ height: 8px;
788
+ border-radius: 50%;
789
+ background: #64748b;
790
+ flex-shrink: 0;
791
+ }
792
+ .dm-mode-indicator--realtime .dm-mode-dot {
793
+ background: #22C55E;
794
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
795
+ animation: dm-mode-pulse 2s ease-in-out infinite;
796
+ }
797
+ @keyframes dm-mode-pulse {
798
+ 0%, 100% { box-shadow: 0 0 8px rgba(34, 197, 94, 0.6); }
799
+ 50% { box-shadow: 0 0 16px rgba(34, 197, 94, 0.9); }
800
+ }
801
+ .dm-retry-btn {
802
+ display: inline-flex;
803
+ align-items: center;
804
+ gap: 4px;
805
+ padding: 4px 10px;
806
+ background: rgba(255, 255, 255, 0.08);
807
+ border: 1px solid rgba(255, 255, 255, 0.15);
808
+ border-radius: 6px;
809
+ color: #94a3b8;
810
+ font-size: 11px;
811
+ font-weight: 500;
812
+ cursor: pointer;
813
+ transition: all 0.15s ease;
814
+ }
815
+ .dm-retry-btn:hover {
816
+ background: rgba(255, 255, 255, 0.12);
817
+ color: #e2e8f0;
818
+ border-color: rgba(255, 255, 255, 0.25);
819
+ }
820
+
821
+ /* Prefers reduced motion */
822
+ @media (prefers-reduced-motion: reduce) {
823
+ *, *::before, *::after {
824
+ animation-duration: 0.01ms !important;
825
+ transition-duration: 0.01ms !important;
826
+ }
827
+ }
828
+ </style>
829
+ </head>
830
+
831
+ <body>
832
+ <!-- Navbar -->
833
+ <nav class="site-nav">
834
+ <div style="max-width:1280px; margin:0 auto; padding:0 1.5rem; display:flex; align-items:center; justify-content:space-between; height:56px;">
835
+ <div style="display:flex; align-items:center; gap:0.75rem;">
836
+ <!-- Logo mark -->
837
+ <a href="/devformance" style="display:flex; align-items:center; gap:0.75rem; text-decoration:none;">
838
+ <%= image_tag icon_svg_path, alt: "Devformance", style: "width:28px; height:28px;" %>
839
+ <span style="font-family:var(--font-mono); font-weight:700; font-size:1rem; color:var(--text-primary); letter-spacing:-0.01em;">
840
+ <span style="color:var(--green);">Dev</span>formance
841
+ </span>
842
+ </a>
843
+ </div>
844
+
845
+ <div style="display:flex; align-items:center; gap:1rem;">
846
+ <%# Navbar right content %>
847
+ </div>
848
+ </div>
849
+ </nav>
850
+
851
+ <!-- Main content -->
852
+ <main style="max-width:1280px; margin:0 auto; padding:2rem 1.5rem 4rem;">
853
+ <%= yield %>
854
+ </main>
855
+ </body>
856
+ </html>