rails_nexus 2.0.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 (114) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +430 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/config/rails_nexus_manifest.js +2 -0
  6. data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
  7. data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
  9. data/app/controllers/rails_nexus/application_controller.rb +15 -0
  10. data/app/controllers/rails_nexus/backup_controller.rb +91 -0
  11. data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
  12. data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
  13. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
  14. data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
  15. data/app/controllers/rails_nexus/settings_controller.rb +100 -0
  16. data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
  17. data/app/controllers/rails_nexus/stats_controller.rb +494 -0
  18. data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
  19. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  20. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
  21. data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
  22. data/app/javascript/controllers/rails_nexus_controller.js +380 -0
  23. data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  25. data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
  26. data/app/javascript/rails_nexus/application.js +17 -0
  27. data/app/jobs/rails_nexus/application_job.rb +4 -0
  28. data/app/mailers/rails_nexus/application_mailer.rb +6 -0
  29. data/app/models/rails_nexus/application_record.rb +5 -0
  30. data/app/models/rails_nexus/backup.rb +107 -0
  31. data/app/models/rails_nexus/base_record.rb +26 -0
  32. data/app/models/rails_nexus/comment.rb +13 -0
  33. data/app/models/rails_nexus/cron_job.rb +72 -0
  34. data/app/models/rails_nexus/database_stat.rb +128 -0
  35. data/app/models/rails_nexus/event.rb +101 -0
  36. data/app/models/rails_nexus/logged_exception.rb +864 -0
  37. data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
  38. data/app/models/rails_nexus/metric.rb +107 -0
  39. data/app/models/rails_nexus/nginx_metric.rb +112 -0
  40. data/app/models/rails_nexus/server_metric.rb +168 -0
  41. data/app/models/rails_nexus/webhook_delivery.rb +64 -0
  42. data/app/services/rails_nexus/backup_service.rb +275 -0
  43. data/app/views/layouts/rails_nexus/application.html.erb +185 -0
  44. data/app/views/rails_nexus/analytics/index.html.erb +363 -0
  45. data/app/views/rails_nexus/backup/files.html.erb +53 -0
  46. data/app/views/rails_nexus/backup/index.html.erb +211 -0
  47. data/app/views/rails_nexus/backup/settings.html.erb +158 -0
  48. data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
  49. data/app/views/rails_nexus/database_health/index.html.erb +139 -0
  50. data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
  51. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
  52. data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
  53. data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
  54. data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
  55. data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
  56. data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
  57. data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
  58. data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
  59. data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
  60. data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
  61. data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
  62. data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
  63. data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
  64. data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
  65. data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
  66. data/app/views/rails_nexus/settings/index.html.erb +140 -0
  67. data/app/views/rails_nexus/stats/index.html.erb +569 -0
  68. data/config/importmap.rb +15 -0
  69. data/config/initializers/date_formats.rb +5 -0
  70. data/config/initializers/rails_nexus.rb +18 -0
  71. data/config/locales/en.yml +47 -0
  72. data/config/routes.rb +58 -0
  73. data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
  74. data/lib/generators/rails_nexus/backup_generator.rb +70 -0
  75. data/lib/generators/rails_nexus/customize_generator.rb +159 -0
  76. data/lib/generators/rails_nexus/install_generator.rb +61 -0
  77. data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
  78. data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
  79. data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
  80. data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
  81. data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
  82. data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
  83. data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
  84. data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
  85. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
  86. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
  87. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  88. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
  89. data/lib/generators/rails_nexus/templates/migration.rb +24 -0
  90. data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
  91. data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
  92. data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
  93. data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
  94. data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
  95. data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
  96. data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
  97. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
  98. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
  99. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
  100. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
  101. data/lib/rails_nexus/breadcrumbs.rb +161 -0
  102. data/lib/rails_nexus/cleanup.rb +32 -0
  103. data/lib/rails_nexus/configuration.rb +319 -0
  104. data/lib/rails_nexus/engine.rb +135 -0
  105. data/lib/rails_nexus/logger.rb +188 -0
  106. data/lib/rails_nexus/notifications.rb +105 -0
  107. data/lib/rails_nexus/ransack_config.rb +4 -0
  108. data/lib/rails_nexus/storm_protection.rb +124 -0
  109. data/lib/rails_nexus/version.rb +3 -0
  110. data/lib/rails_nexus.rb +133 -0
  111. data/lib/tasks/rails_nexus.rake +230 -0
  112. data/lib/tasks/rails_nexus_tasks.rake +37 -0
  113. data/rails_nexus.gemspec +36 -0
  114. metadata +252 -0
@@ -0,0 +1,380 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["activity", "shortcutsDialog", "tabs", "tabContent", "exceptionList", "backtraceHidden", "backtraceContent"]
5
+ static values = { shortcuts: Boolean }
6
+
7
+ connect() {
8
+ this.selectedIndex = -1
9
+ this.rows = []
10
+
11
+ this.showActivity = this.showActivity.bind(this)
12
+ this.hideActivity = this.hideActivity.bind(this)
13
+
14
+ document.addEventListener("turbo:before-fetch-request", this.showActivity)
15
+ document.addEventListener("turbo:before-fetch-response", this.hideActivity)
16
+ document.addEventListener("turbo:submit-end", this.hideActivity)
17
+
18
+ if (this.shortcutsValue) {
19
+ this.boundKeydown = this.handleKeydown.bind(this)
20
+ document.addEventListener("keydown", this.boundKeydown)
21
+ }
22
+ }
23
+
24
+ disconnect() {
25
+ document.removeEventListener("turbo:before-fetch-request", this.showActivity)
26
+ document.removeEventListener("turbo:before-fetch-response", this.hideActivity)
27
+ document.removeEventListener("turbo:submit-end", this.hideActivity)
28
+
29
+ if (this.boundKeydown) {
30
+ document.removeEventListener("keydown", this.boundKeydown)
31
+ }
32
+ }
33
+
34
+ // ─── Keyboard Navigation ──────────────────────────────────
35
+
36
+ handleKeydown(event) {
37
+ // Ignore when typing in an input/textarea/select
38
+ const tag = event.target.tagName.toLowerCase()
39
+ if (tag === "input" || tag === "textarea" || tag === "select") {
40
+ if (event.key === "Escape") {
41
+ event.target.blur()
42
+ event.preventDefault()
43
+ }
44
+ return
45
+ }
46
+
47
+ switch (event.key) {
48
+ case "/":
49
+ event.preventDefault()
50
+ this.focusSearch()
51
+ break
52
+ case "j":
53
+ case "ArrowDown":
54
+ event.preventDefault()
55
+ this.navigateDown()
56
+ break
57
+ case "k":
58
+ case "ArrowUp":
59
+ event.preventDefault()
60
+ this.navigateUp()
61
+ break
62
+ case "Enter":
63
+ event.preventDefault()
64
+ this.openSelected()
65
+ break
66
+ case "p":
67
+ case "ArrowLeft":
68
+ event.preventDefault()
69
+ this.navigateToPrev()
70
+ break
71
+ case "n":
72
+ case "ArrowRight":
73
+ event.preventDefault()
74
+ this.navigateToNext()
75
+ break
76
+ case "t":
77
+ event.preventDefault()
78
+ this.toggleTheme()
79
+ break
80
+ case "Escape":
81
+ event.preventDefault()
82
+ this.closeDetails(event)
83
+ this.closeShortcuts()
84
+ break
85
+ case "?":
86
+ event.preventDefault()
87
+ this.toggleShortcuts()
88
+ break
89
+ case "1": case "2": case "3": case "4": case "5":
90
+ event.preventDefault()
91
+ this.switchTabByIndex(parseInt(event.key) - 1)
92
+ break
93
+ }
94
+ }
95
+
96
+ focusSearch() {
97
+ const search = this.element.querySelector(".rn-search .rn-input, input[type='search']")
98
+ if (search) {
99
+ search.focus()
100
+ search.select()
101
+ }
102
+ }
103
+
104
+ refreshRows() {
105
+ if (this.hasExceptionListTarget) {
106
+ this.rows = Array.from(this.exceptionListTarget.querySelectorAll("tr[data-url]"))
107
+ } else {
108
+ this.rows = Array.from(this.element.querySelectorAll("tr[data-url]"))
109
+ }
110
+ }
111
+
112
+ navigateDown() {
113
+ this.refreshRows()
114
+ if (this.rows.length === 0) return
115
+
116
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.rows.length - 1)
117
+ this.highlightRow()
118
+ }
119
+
120
+ navigateUp() {
121
+ this.refreshRows()
122
+ if (this.rows.length === 0) return
123
+
124
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
125
+ this.highlightRow()
126
+ }
127
+
128
+ highlightRow() {
129
+ this.rows.forEach((row, i) => {
130
+ row.classList.toggle("selected", i === this.selectedIndex)
131
+ })
132
+
133
+ const row = this.rows[this.selectedIndex]
134
+ if (row) {
135
+ row.scrollIntoView({ block: "nearest", behavior: "smooth" })
136
+ }
137
+ }
138
+
139
+ openSelected() {
140
+ this.refreshRows()
141
+ if (this.selectedIndex < 0 || this.selectedIndex >= this.rows.length) return
142
+
143
+ const row = this.rows[this.selectedIndex]
144
+ const url = row.dataset.url
145
+ if (url) {
146
+ // Fetch and load into the detail panel
147
+ this.selectRow({ currentTarget: row, preventDefault: () => {} })
148
+ }
149
+ }
150
+
151
+ // ─── Row Selection ────────────────────────────────────────
152
+
153
+ selectRow(event) {
154
+ event.preventDefault()
155
+ const row = event.currentTarget
156
+ const url = row.dataset.url
157
+ if (!url) return
158
+
159
+ // Highlight selected row
160
+ this.refreshRows()
161
+ this.rows.forEach(r => r.classList.remove("selected"))
162
+ row.classList.add("selected")
163
+
164
+ // Show detail panel
165
+ const detailPanel = document.getElementById("exception-detail-desktop")
166
+ if (detailPanel) {
167
+ detailPanel.hidden = false
168
+ detailPanel.classList.remove("hidden")
169
+ }
170
+
171
+ // Load content via Turbo
172
+ const frame = document.getElementById("exception-details")
173
+ if (frame) {
174
+ frame.innerHTML = ""
175
+ frame.src = url
176
+ }
177
+ }
178
+
179
+ stopPropagation(event) {
180
+ event.stopPropagation()
181
+ }
182
+
183
+ // ─── Close Details ────────────────────────────────────────
184
+
185
+ closeDetails(event) {
186
+ event.preventDefault()
187
+
188
+ // Deselect all rows
189
+ this.refreshRows()
190
+ this.rows.forEach(r => r.classList.remove("selected"))
191
+ this.selectedIndex = -1
192
+
193
+ // Hide detail panel
194
+ const detailPanel = document.getElementById("exception-detail-desktop")
195
+ if (detailPanel) {
196
+ detailPanel.hidden = true
197
+ detailPanel.classList.add("hidden")
198
+ }
199
+
200
+ // Clear the turbo frame
201
+ const frame = document.getElementById("exception-details")
202
+ if (frame) {
203
+ frame.innerHTML = `
204
+ <div class="text-center py-12">
205
+ <div class="w-10 h-10 rounded-full flex items-center justify-center mx-auto" style="background: var(--rn-bg-secondary)">
206
+ <svg class="w-5 h-5" style="color: var(--rn-text-muted)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
207
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
208
+ </svg>
209
+ </div>
210
+ <p class="mt-2 text-xs" style="color: var(--rn-text-muted)">Select an exception to view details</p>
211
+ <p class="mt-1 text-xs" style="color: var(--rn-text-muted)">Click a row or press <span class="rn-kbd">↓</span> <span class="rn-kbd">↑</span> to navigate</p>
212
+ </div>
213
+ `
214
+ }
215
+ }
216
+
217
+ // ─── Tab Switching ────────────────────────────────────────
218
+
219
+ switchTab(event) {
220
+ const tabName = event.currentTarget.dataset.flTabParam || event.currentTarget.dataset.flTab
221
+ this.activateTab(tabName)
222
+ }
223
+
224
+ switchTabByIndex(index) {
225
+ if (!this.hasTabsTarget) return
226
+ const tabs = this.tabsTarget.querySelectorAll(".rn-tab")
227
+ if (index >= 0 && index < tabs.length) {
228
+ const tabName = tabs[index].dataset.flTabParam || tabs[index].dataset.flTab
229
+ this.activateTab(tabName)
230
+ }
231
+ }
232
+
233
+ activateTab(tabName) {
234
+ // Update tab buttons
235
+ if (this.hasTabsTarget) {
236
+ this.tabsTarget.querySelectorAll(".rn-tab").forEach(tab => {
237
+ const isActive = (tab.dataset.flTabParam || tab.dataset.flTab) === tabName
238
+ tab.classList.toggle("active", isActive)
239
+ })
240
+ }
241
+
242
+ // Show/hide tab content
243
+ this.tabContentTargets.forEach(content => {
244
+ const isTarget = content.dataset.flTabContent === tabName
245
+ content.style.display = isTarget ? "" : "none"
246
+ })
247
+ }
248
+
249
+ // ─── Theme Toggle ─────────────────────────────────────────
250
+
251
+ toggleTheme() {
252
+ const themeController = this.application.getControllerForElementAndIdentifier(
253
+ document.documentElement,
254
+ "rails_nexus-theme"
255
+ )
256
+ if (themeController) {
257
+ themeController.toggle()
258
+ } else {
259
+ // Fallback: direct DOM manipulation
260
+ const root = document.documentElement
261
+ const isDark = root.classList.contains("dark")
262
+ root.classList.remove("dark", "light")
263
+ root.classList.add(isDark ? "light" : "dark")
264
+ localStorage.setItem("rails_nexus-theme", isDark ? "light" : "dark")
265
+ }
266
+ }
267
+
268
+ // ─── Keyboard Shortcuts Dialog ────────────────────────────
269
+
270
+ toggleShortcuts() {
271
+ if (!this.hasShortcutsDialogTarget) return
272
+ const isOpen = this.shortcutsDialogTarget.classList.contains("open")
273
+ if (isOpen) {
274
+ this.closeShortcuts()
275
+ } else {
276
+ this.shortcutsDialogTarget.classList.add("open")
277
+ }
278
+ }
279
+
280
+ closeShortcuts() {
281
+ if (this.hasShortcutsDialogTarget) {
282
+ this.shortcutsDialogTarget.classList.remove("open")
283
+ }
284
+ }
285
+
286
+ // ─── Navigation (prev/next) ───────────────────────────────
287
+
288
+ navigateToPrev() {
289
+ const prevLink = document.querySelector(".rn-nav-link[href*='logged_exceptions']")
290
+ if (prevLink && prevLink.querySelector(".rn-nav-link-label")?.textContent === "Previous") {
291
+ prevLink.click()
292
+ }
293
+ }
294
+
295
+ navigateToNext() {
296
+ const links = document.querySelectorAll(".rn-nav-link[href*='logged_exceptions']")
297
+ const nextLink = Array.from(links).find(link =>
298
+ link.querySelector(".rn-nav-link-label")?.textContent === "Next"
299
+ )
300
+ if (nextLink) nextLink.click()
301
+ }
302
+
303
+ // ─── Copy to Clipboard ───────────────────────────────────
304
+
305
+ copyId(event) {
306
+ const id = event.currentTarget.dataset.rails_nexusIdValue
307
+ if (id) this.copyToClipboard(id, event.currentTarget)
308
+ }
309
+
310
+ copyMessage(event) {
311
+ const message = event.currentTarget.dataset.rails_nexusMessageValue
312
+ if (message) this.copyToClipboard(message, event.currentTarget)
313
+ }
314
+
315
+ copyBacktrace(event) {
316
+ const backtrace = event.currentTarget.dataset.rails_nexusBacktraceValue
317
+ if (backtrace) this.copyToClipboard(backtrace, event.currentTarget)
318
+ }
319
+
320
+ async copyToClipboard(text, button) {
321
+ try {
322
+ await navigator.clipboard.writeText(text)
323
+ const originalHTML = button.innerHTML
324
+ button.classList.add("copied")
325
+ button.innerHTML = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> Copied!`
326
+ setTimeout(() => {
327
+ button.classList.remove("copied")
328
+ button.innerHTML = originalHTML
329
+ }, 2000)
330
+ } catch (err) {
331
+ // Fallback for older browsers
332
+ const textarea = document.createElement("textarea")
333
+ textarea.value = text
334
+ textarea.style.position = "fixed"
335
+ textarea.style.opacity = "0"
336
+ document.body.appendChild(textarea)
337
+ textarea.select()
338
+ document.execCommand("copy")
339
+ document.body.removeChild(textarea)
340
+ }
341
+ }
342
+
343
+ // ─── Backtrace Expand ─────────────────────────────────────
344
+
345
+ expandBacktrace(event) {
346
+ event.preventDefault()
347
+ const hidden = this.backtraceHiddenTarget
348
+ if (hidden) {
349
+ hidden.style.display = hidden.style.display === "none" ? "" : "none"
350
+ event.currentTarget.textContent = hidden.style.display === "none"
351
+ ? `Show ${hidden.textContent.split("\n").length} more frames`
352
+ : "Hide additional frames"
353
+ }
354
+ }
355
+
356
+ // ─── Submit Helpers ───────────────────────────────────────
357
+
358
+ submit() {
359
+ this.showActivity()
360
+ const form = this.element.querySelector("form[data-turbo-frame]")
361
+ if (form) form.requestSubmit()
362
+ }
363
+
364
+ debouncedSubmit(event) {
365
+ clearTimeout(this._debounceTimer)
366
+ this._debounceTimer = setTimeout(() => {
367
+ this.submit()
368
+ }, 400)
369
+ }
370
+
371
+ // ─── Loading State ────────────────────────────────────────
372
+
373
+ showActivity() {
374
+ this.activityTarget?.classList.remove("hidden")
375
+ }
376
+
377
+ hideActivity() {
378
+ this.activityTarget?.classList.add("hidden")
379
+ }
380
+ }
@@ -0,0 +1,165 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tabs", "tabContent", "backtraceContent", "backtraceHidden", "sourcePanel"]
5
+
6
+ // ─── Tab Switching ────────────────────────────────────────
7
+
8
+ switchTab(event) {
9
+ const tabName = event.params.tab || event.currentTarget.getAttribute("data-rn-tab-param")
10
+ if (!tabName) return
11
+
12
+ // Update tab buttons
13
+ this.tabsTarget.querySelectorAll(".rn-tab").forEach(tab => {
14
+ const isActive = tab.getAttribute("data-rn-tab-param") === tabName
15
+ tab.classList.toggle("active", isActive)
16
+ })
17
+
18
+ // Show/hide tab content
19
+ this.tabContentTargets.forEach(content => {
20
+ const isTarget = content.getAttribute("data-rn-tab-content") === tabName
21
+ content.style.display = isTarget ? "" : "none"
22
+ })
23
+ }
24
+
25
+ // ─── Copy to Clipboard ───────────────────────────────────
26
+
27
+ copyId(event) {
28
+ const id = event.currentTarget.getAttribute("data-rails_nexus-detail-id-value") ||
29
+ event.currentTarget.dataset.rails_nexusDetailIdValue
30
+ if (id) this.copyToClipboard(id, event.currentTarget)
31
+ }
32
+
33
+ copyMessage(event) {
34
+ const msg = event.currentTarget.getAttribute("data-rails_nexus-detail-message-value") ||
35
+ event.currentTarget.dataset.rails_nexusDetailMessageValue
36
+ if (msg) this.copyToClipboard(msg, event.currentTarget)
37
+ }
38
+
39
+ copyBacktrace(event) {
40
+ const bt = event.currentTarget.getAttribute("data-rails_nexus-detail-backtrace-value") ||
41
+ event.currentTarget.dataset.rails_nexusDetailBacktraceValue
42
+ if (bt) this.copyToClipboard(bt, event.currentTarget)
43
+ }
44
+
45
+ async copyToClipboard(text, button) {
46
+ try {
47
+ await navigator.clipboard.writeText(text)
48
+ } catch {
49
+ const textarea = document.createElement("textarea")
50
+ textarea.value = text
51
+ textarea.style.position = "fixed"
52
+ textarea.style.opacity = "0"
53
+ document.body.appendChild(textarea)
54
+ textarea.select()
55
+ document.execCommand("copy")
56
+ document.body.removeChild(textarea)
57
+ }
58
+ const orig = button.innerHTML
59
+ button.classList.add("copied")
60
+ button.innerHTML = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> Copied!`
61
+ setTimeout(() => { button.classList.remove("copied"); button.innerHTML = orig }, 2000)
62
+ }
63
+
64
+ // ─── Backtrace Expand ────────────────────────────────────
65
+
66
+ expandBacktrace(event) {
67
+ event.preventDefault()
68
+ const hidden = this.backtraceHiddenTarget
69
+ if (hidden) {
70
+ hidden.style.display = hidden.style.display === "none" ? "" : "none"
71
+ event.currentTarget.textContent = hidden.style.display === "none"
72
+ ? "Show more frames"
73
+ : "Hide additional frames"
74
+ }
75
+ }
76
+
77
+ // ─── Source Code Toggle ─────────────────────────────────
78
+
79
+ toggleSource(event) {
80
+ event.preventDefault()
81
+ const btn = event.currentTarget
82
+ const file = btn.dataset.file
83
+ const line = btn.dataset.line
84
+
85
+ // Find the source panel for this frame
86
+ const panel = this.sourcePanelTargets.find(p =>
87
+ p.dataset.file === file && p.dataset.line === line
88
+ )
89
+ if (!panel) return
90
+
91
+ if (panel.style.display === "none") {
92
+ // Load source code via AJAX
93
+ panel.style.display = ""
94
+ const loading = panel.querySelector(".rn-source-loading")
95
+ if (loading) loading.style.display = ""
96
+
97
+ fetch(`/rails_nexus/source_code?file=${encodeURIComponent(file)}&line=${line}&context=5`, {
98
+ headers: { "Accept": "application/json" }
99
+ })
100
+ .then(r => r.json())
101
+ .then(data => {
102
+ if (data.source) {
103
+ panel.innerHTML = this.renderSourceSnippet(data.source, data.blame)
104
+ } else {
105
+ panel.innerHTML = `<div class="text-xs" style="color: var(--rn-text-muted); padding: 0.5rem">Source not available</div>`
106
+ }
107
+ })
108
+ .catch(() => {
109
+ panel.innerHTML = `<div class="text-xs" style="color: var(--rn-text-muted); padding: 0.5rem">Failed to load source</div>`
110
+ })
111
+ } else {
112
+ panel.style.display = "none"
113
+ }
114
+ }
115
+
116
+ renderSourceSnippet(snippet, blame) {
117
+ let html = `<div class="rn-source-code rounded" style="background: var(--rn-bg-primary); border: 1px solid var(--rn-border-light); overflow-x: auto; font-size: 0.75rem; font-family: monospace">`
118
+
119
+ // Git blame header
120
+ if (blame) {
121
+ html += `<div class="flex items-center gap-2 px-3 py-1.5" style="background: var(--rn-bg-secondary); border-bottom: 1px solid var(--rn-border-light); font-size: 0.7rem; color: var(--rn-text-muted)">`
122
+ html += `<span style="color: var(--rn-primary)">${blame.author || 'unknown'}</span>`
123
+ html += `<span>·</span>`
124
+ html += `<span>${blame.date || ''}</span>`
125
+ html += `<span>·</span>`
126
+ html += `<span style="color: var(--rn-text-muted)">${blame.message || ''}</span>`
127
+ html += `<span class="ml-auto font-mono" style="color: var(--rn-text-muted)">${blame.sha || ''}</span>`
128
+ html += `</div>`
129
+ }
130
+
131
+ // Source lines
132
+ snippet.lines.forEach((line) => {
133
+ const bg = line.is_error ? 'var(--rn-bg-warning-subtle)' : 'transparent'
134
+ html += `<div class="flex" style="background: ${bg}; padding: 0 0.75rem">`
135
+ html += `<span style="min-width: 2.5rem; text-align: right; color: var(--rn-text-muted); user-select: none; padding-right: 1rem">${line.number}</span>`
136
+ html += `<span style="white-space: pre; color: var(--rn-text)">${(line.content || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>`
137
+ html += `</div>`
138
+ })
139
+
140
+ html += `</div>`
141
+ return html
142
+ }
143
+
144
+ // ─── Close Details ───────────────────────────────────────
145
+
146
+ closeDetails() {
147
+ const frame = document.getElementById("exception-details")
148
+ if (frame) {
149
+ frame.innerHTML = `
150
+ <div class="text-center py-12">
151
+ <div class="w-10 h-10 rounded-full flex items-center justify-center mx-auto" style="background: var(--rn-bg-secondary)">
152
+ <svg class="w-5 h-5" style="color: var(--rn-text-muted)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
153
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
154
+ </svg>
155
+ </div>
156
+ <p class="mt-2 text-xs" style="color: var(--rn-text-muted)">Select an exception to view details</p>
157
+ <p class="mt-1 text-xs" style="color: var(--rn-text-muted)">Click a row or press <span class="rn-kbd">↓</span> <span class="rn-kbd">↑</span> to navigate</p>
158
+ </div>
159
+ `
160
+ }
161
+
162
+ // Deselect rows
163
+ document.querySelectorAll(".rn-table tbody tr.selected").forEach(r => r.classList.remove("selected"))
164
+ }
165
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["drawer", "overlay"]
5
+
6
+ toggle() {
7
+ this.drawerTarget.classList.toggle("open")
8
+ this.overlayTarget.classList.toggle("open")
9
+ }
10
+
11
+ close() {
12
+ this.drawerTarget.classList.remove("open")
13
+ this.overlayTarget.classList.remove("open")
14
+ }
15
+ }
@@ -0,0 +1,31 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { theme: String }
5
+
6
+ connect() {
7
+ const saved = localStorage.getItem("rails_nexus-theme")
8
+ if (saved) {
9
+ this.applyTheme(saved)
10
+ } else if (this.themeValue && this.themeValue !== "auto") {
11
+ this.applyTheme(this.themeValue)
12
+ } else {
13
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
14
+ this.applyTheme(prefersDark ? "dark" : "light")
15
+ }
16
+ }
17
+
18
+ toggle() {
19
+ const root = document.documentElement
20
+ const isDark = root.classList.contains("dark")
21
+ const newTheme = isDark ? "light" : "dark"
22
+ this.applyTheme(newTheme)
23
+ }
24
+
25
+ applyTheme(theme) {
26
+ const root = document.documentElement
27
+ root.classList.remove("dark", "light")
28
+ root.classList.add(theme)
29
+ localStorage.setItem("rails_nexus-theme", theme)
30
+ }
31
+ }
@@ -0,0 +1,17 @@
1
+ import "@hotwired/turbo-rails"
2
+ import { Application } from "@hotwired/stimulus"
3
+
4
+ import RailsNexusController from "rails_nexus/controllers/rails_nexus"
5
+ import RailsNexusDetailController from "rails_nexus/controllers/rails_nexus_detail"
6
+ import RailsNexusSidebarController from "rails_nexus/controllers/rails_nexus_sidebar"
7
+ import RailsNexusThemeController from "rails_nexus/controllers/rails_nexus_theme"
8
+
9
+ // Keep the engine independent from the host application's Stimulus setup.
10
+ const application = Application.start()
11
+ application.debug = false
12
+
13
+ // Register engine controllers
14
+ application.register("rails_nexus", RailsNexusController)
15
+ application.register("rails_nexus-theme", RailsNexusThemeController)
16
+ application.register("rails_nexus-sidebar", RailsNexusSidebarController)
17
+ application.register("rails_nexus-detail", RailsNexusDetailController)
@@ -0,0 +1,4 @@
1
+ module RailsNexus
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module RailsNexus
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module RailsNexus
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end