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,43 @@
1
+ # frozen_string_literal: true
2
+ # Sync backup template — remote sync to backup server
3
+
4
+ model :sync_backup do
5
+ # Database
6
+ database Mysql do |db|
7
+ db.name = ENV["DATABASE_NAME"] || "myapp_production"
8
+ db.username = ENV["DATABASE_USER"] || "root"
9
+ db.password = ENV["DATABASE_PASSWORD"] || ""
10
+ db.host = ENV["DATABASE_HOST"] || "localhost"
11
+ db.port = (ENV["DATABASE_PORT"] || 3306).to_i
12
+
13
+ # Skip large/temporary tables
14
+ skip_tables = ENV["BACKUP_SKIP_TABLES"] || "sessions,caches,active_storage"
15
+ skip_tables.split(",").each { |t| db.skip_tables << t.strip }
16
+ end
17
+
18
+ # Compress with Gzip
19
+ compress_with Gzip
20
+
21
+ # Store locally (staging area)
22
+ store_with Local do |local|
23
+ local.path = ENV["BACKUP_DUMP_PATH"] || File.expand_path("storage/rails_nexus/backups/sync", Rails.root)
24
+ local.keep = 1
25
+ end
26
+
27
+ # Sync to remote server
28
+ notify_by Sync do |sync|
29
+ sync.directories do |directory|
30
+ directory.path = ENV["BACKUP_DUMP_PATH"] || File.expand_path("storage/rails_nexus/backups/sync", Rails.root)
31
+ end
32
+
33
+ connection do |conn|
34
+ conn.host = ENV["BACKUP_RSYNC_HOST"] || "backup.example.com"
35
+ conn.port = ENV["BACKUP_RSYNC_PORT"] || 22
36
+ conn.username = ENV["BACKUP_RSYNC_USER"] || "backup"
37
+ conn.ssh_options = {
38
+ keys: ENV["BACKUP_SSH_KEY"] || File.expand_path("~/.ssh/id_rsa"),
39
+ timeout: 30
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ # RailsNexus Backup MySQL Configuration
2
+ # Generated by: rails generate rails_nexus:backup
3
+ #
4
+ # This file is used by the backup gem for database authentication.
5
+ # Keep this file secure and do not commit to version control.
6
+
7
+ [client]
8
+ user=<%= ENV['DATABASE_USER'] || 'root' %>
9
+ password=<%= ENV['DATABASE_PASSWORD'] || '' %>
10
+ host=<%= ENV['DATABASE_HOST'] || 'localhost' %>
11
+ port=<%= (ENV['DATABASE_PORT'] || 3306).to_i %>
12
+ database=<%= ENV['DATABASE_NAME'] || 'myapp_production' %>
13
+
14
+ # Dump options
15
+ default-character-set=utf8mb4
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ # RailsNexus Backup Schedule
3
+ # Generated by: rails generate rails_nexus:backup
4
+ #
5
+ # This file is parsed by the BackupService to show schedule info in the UI.
6
+ # Actual cron should be installed separately: whenever --write-crontab
7
+
8
+ # ─── Backup Schedule ──────────────────────────────────────────────
9
+
10
+ # Full backup daily at 2:00 AM (all tables, local only)
11
+ every 1.day, at: "2:00 am" do
12
+ command "cd #{File.expand_path('../..', __FILE__)} && bundle exec backup perform -t full_backup --triggered_by system"
13
+ end
14
+
15
+ # Incremental backup every 4 hours (skipping large tables)
16
+ every 4.hours do
17
+ command "cd #{File.expand_path('../..', __FILE__)} && bundle exec backup perform -t incremental_backup --triggered_by system"
18
+ end
19
+
20
+ # Remote sync weekly on Sunday at 3:00 AM
21
+ every 1.week, at: "3:00 am" do
22
+ command "cd #{File.expand_path('../..', __FILE__)} && bundle exec backup perform -t sync_backup --triggered_by system"
23
+ end
24
+
25
+ # Cleanup old backups monthly
26
+ every 1.month, at: "4:00 am" do
27
+ command "cd #{File.expand_path('../..', __FILE__)} && bundle exec backup perform -t full_backup --cleanup"
28
+ end
@@ -0,0 +1,310 @@
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
+ handleKeydown(event) {
35
+ const tag = event.target.tagName.toLowerCase()
36
+ if (tag === "input" || tag === "textarea" || tag === "select") {
37
+ if (event.key === "Escape") {
38
+ event.target.blur()
39
+ event.preventDefault()
40
+ }
41
+ return
42
+ }
43
+
44
+ switch (event.key) {
45
+ case "/":
46
+ event.preventDefault()
47
+ this.focusSearch()
48
+ break
49
+ case "j":
50
+ case "ArrowDown":
51
+ event.preventDefault()
52
+ this.navigateDown()
53
+ break
54
+ case "k":
55
+ case "ArrowUp":
56
+ event.preventDefault()
57
+ this.navigateUp()
58
+ break
59
+ case "Enter":
60
+ event.preventDefault()
61
+ this.openSelected()
62
+ break
63
+ case "p":
64
+ case "ArrowLeft":
65
+ event.preventDefault()
66
+ this.navigateToPrev()
67
+ break
68
+ case "n":
69
+ case "ArrowRight":
70
+ event.preventDefault()
71
+ this.navigateToNext()
72
+ break
73
+ case "t":
74
+ event.preventDefault()
75
+ this.toggleTheme()
76
+ break
77
+ case "Escape":
78
+ event.preventDefault()
79
+ this.closeDetails(event)
80
+ this.closeShortcuts()
81
+ break
82
+ case "?":
83
+ event.preventDefault()
84
+ this.toggleShortcuts()
85
+ break
86
+ case "1": case "2": case "3": case "4": case "5":
87
+ event.preventDefault()
88
+ this.switchTabByIndex(parseInt(event.key) - 1)
89
+ break
90
+ }
91
+ }
92
+
93
+ focusSearch() {
94
+ const search = this.element.querySelector(".rn-search .rn-input, input[type='search']")
95
+ if (search) {
96
+ search.focus()
97
+ search.select()
98
+ }
99
+ }
100
+
101
+ refreshRows() {
102
+ if (this.hasExceptionListTarget) {
103
+ this.rows = Array.from(this.exceptionListTarget.querySelectorAll("tr[data-url]"))
104
+ } else {
105
+ this.rows = Array.from(this.element.querySelectorAll("tr[data-url]"))
106
+ }
107
+ }
108
+
109
+ navigateDown() {
110
+ this.refreshRows()
111
+ if (this.rows.length === 0) return
112
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.rows.length - 1)
113
+ this.highlightRow()
114
+ }
115
+
116
+ navigateUp() {
117
+ this.refreshRows()
118
+ if (this.rows.length === 0) return
119
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
120
+ this.highlightRow()
121
+ }
122
+
123
+ highlightRow() {
124
+ this.rows.forEach((row, i) => {
125
+ row.classList.toggle("selected", i === this.selectedIndex)
126
+ })
127
+ const row = this.rows[this.selectedIndex]
128
+ if (row) row.scrollIntoView({ block: "nearest", behavior: "smooth" })
129
+ }
130
+
131
+ openSelected() {
132
+ this.refreshRows()
133
+ if (this.selectedIndex < 0 || this.selectedIndex >= this.rows.length) return
134
+ const row = this.rows[this.selectedIndex]
135
+ if (row.dataset.url) {
136
+ this.selectRow({ currentTarget: row, preventDefault: () => {} })
137
+ }
138
+ }
139
+
140
+ selectRow(event) {
141
+ event.preventDefault()
142
+ const row = event.currentTarget
143
+ const url = row.dataset.url
144
+ if (!url) return
145
+
146
+ this.refreshRows()
147
+ this.rows.forEach(r => r.classList.remove("selected"))
148
+ row.classList.add("selected")
149
+
150
+ const detailPanel = document.getElementById("exception-detail-desktop")
151
+ if (detailPanel) {
152
+ detailPanel.hidden = false
153
+ detailPanel.classList.remove("hidden")
154
+ }
155
+
156
+ const frame = document.getElementById("exception-details")
157
+ if (frame) {
158
+ frame.innerHTML = ""
159
+ frame.src = url
160
+ }
161
+ }
162
+
163
+ stopPropagation(event) {
164
+ event.stopPropagation()
165
+ }
166
+
167
+ closeDetails(event) {
168
+ event.preventDefault()
169
+ this.refreshRows()
170
+ this.rows.forEach(r => r.classList.remove("selected"))
171
+ this.selectedIndex = -1
172
+
173
+ const detailPanel = document.getElementById("exception-detail-desktop")
174
+ if (detailPanel) {
175
+ detailPanel.hidden = true
176
+ detailPanel.classList.add("hidden")
177
+ }
178
+
179
+ const frame = document.getElementById("exception-details")
180
+ if (frame) {
181
+ frame.innerHTML = `
182
+ <div class="text-center py-12">
183
+ <div class="w-10 h-10 rounded-full flex items-center justify-center mx-auto" style="background: var(--rn-bg-secondary)">
184
+ <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">
185
+ <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" />
186
+ </svg>
187
+ </div>
188
+ <p class="mt-2 text-xs" style="color: var(--rn-text-muted)">Select an exception to view details</p>
189
+ <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>
190
+ </div>
191
+ `
192
+ }
193
+ }
194
+
195
+ switchTab(event) {
196
+ const tabName = event.currentTarget.dataset.flTabParam || event.currentTarget.dataset.flTab
197
+ this.activateTab(tabName)
198
+ }
199
+
200
+ switchTabByIndex(index) {
201
+ if (!this.hasTabsTarget) return
202
+ const tabs = this.tabsTarget.querySelectorAll(".rn-tab")
203
+ if (index >= 0 && index < tabs.length) {
204
+ const tabName = tabs[index].dataset.flTabParam || tabs[index].dataset.flTab
205
+ this.activateTab(tabName)
206
+ }
207
+ }
208
+
209
+ activateTab(tabName) {
210
+ if (this.hasTabsTarget) {
211
+ this.tabsTarget.querySelectorAll(".rn-tab").forEach(tab => {
212
+ const isActive = (tab.dataset.flTabParam || tab.dataset.flTab) === tabName
213
+ tab.classList.toggle("active", isActive)
214
+ })
215
+ }
216
+ this.tabContentTargets.forEach(content => {
217
+ const isTarget = content.dataset.flTabContent === tabName
218
+ content.style.display = isTarget ? "" : "none"
219
+ })
220
+ }
221
+
222
+ toggleTheme() {
223
+ const themeController = this.application.getControllerForElementAndIdentifier(
224
+ document.documentElement, "rails_nexus-theme"
225
+ )
226
+ if (themeController) {
227
+ themeController.toggle()
228
+ } else {
229
+ const root = document.documentElement
230
+ const isDark = root.classList.contains("dark")
231
+ root.classList.remove("dark", "light")
232
+ root.classList.add(isDark ? "light" : "dark")
233
+ localStorage.setItem("rails_nexus-theme", isDark ? "light" : "dark")
234
+ }
235
+ }
236
+
237
+ toggleShortcuts() {
238
+ if (!this.hasShortcutsDialogTarget) return
239
+ const isOpen = this.shortcutsDialogTarget.classList.contains("open")
240
+ if (isOpen) { this.closeShortcuts() } else { this.shortcutsDialogTarget.classList.add("open") }
241
+ }
242
+
243
+ closeShortcuts() {
244
+ if (this.hasShortcutsDialogTarget) this.shortcutsDialogTarget.classList.remove("open")
245
+ }
246
+
247
+ navigateToPrev() {
248
+ const prevLink = document.querySelector(".rn-nav-link[href*='logged_exceptions']")
249
+ if (prevLink && prevLink.querySelector(".rn-nav-link-label")?.textContent === "Previous") prevLink.click()
250
+ }
251
+
252
+ navigateToNext() {
253
+ const links = document.querySelectorAll(".rn-nav-link[href*='logged_exceptions']")
254
+ const nextLink = Array.from(links).find(l => l.querySelector(".rn-nav-link-label")?.textContent === "Next")
255
+ if (nextLink) nextLink.click()
256
+ }
257
+
258
+ copyId(event) {
259
+ const id = event.currentTarget.dataset.rails_nexusIdValue
260
+ if (id) this.copyToClipboard(id, event.currentTarget)
261
+ }
262
+
263
+ copyMessage(event) {
264
+ const msg = event.currentTarget.dataset.rails_nexusMessageValue
265
+ if (msg) this.copyToClipboard(msg, event.currentTarget)
266
+ }
267
+
268
+ copyBacktrace(event) {
269
+ const bt = event.currentTarget.dataset.rails_nexusBacktraceValue
270
+ if (bt) this.copyToClipboard(bt, event.currentTarget)
271
+ }
272
+
273
+ async copyToClipboard(text, button) {
274
+ try {
275
+ await navigator.clipboard.writeText(text)
276
+ } catch {
277
+ const ta = document.createElement("textarea")
278
+ ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"
279
+ document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta)
280
+ }
281
+ const orig = button.innerHTML
282
+ button.classList.add("copied")
283
+ 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!`
284
+ setTimeout(() => { button.classList.remove("copied"); button.innerHTML = orig }, 2000)
285
+ }
286
+
287
+ expandBacktrace(event) {
288
+ event.preventDefault()
289
+ const hidden = this.backtraceHiddenTarget
290
+ if (hidden) {
291
+ hidden.style.display = hidden.style.display === "none" ? "" : "none"
292
+ event.currentTarget.textContent = hidden.style.display === "none"
293
+ ? `Show more frames` : "Hide additional frames"
294
+ }
295
+ }
296
+
297
+ submit() {
298
+ this.showActivity()
299
+ const form = this.element.querySelector("form[data-turbo-frame]")
300
+ if (form) form.requestSubmit()
301
+ }
302
+
303
+ debouncedSubmit() {
304
+ clearTimeout(this._debounceTimer)
305
+ this._debounceTimer = setTimeout(() => this.submit(), 400)
306
+ }
307
+
308
+ showActivity() { this.activityTarget?.classList.remove("hidden") }
309
+ hideActivity() { this.activityTarget?.classList.add("hidden") }
310
+ }
@@ -0,0 +1,98 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tabs", "tabContent", "backtraceContent", "backtraceHidden"]
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
+ // ─── Close Details ───────────────────────────────────────
78
+
79
+ closeDetails() {
80
+ const frame = document.getElementById("exception-details")
81
+ if (frame) {
82
+ frame.innerHTML = `
83
+ <div class="text-center py-12">
84
+ <div class="w-10 h-10 rounded-full flex items-center justify-center mx-auto" style="background: var(--rn-bg-secondary)">
85
+ <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">
86
+ <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" />
87
+ </svg>
88
+ </div>
89
+ <p class="mt-2 text-xs" style="color: var(--rn-text-muted)">Select an exception to view details</p>
90
+ <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>
91
+ </div>
92
+ `
93
+ }
94
+
95
+ // Deselect rows
96
+ document.querySelectorAll(".rn-table tbody tr.selected").forEach(r => r.classList.remove("selected"))
97
+ }
98
+ }
@@ -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,30 @@
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
+ this.applyTheme(isDark ? "light" : "dark")
22
+ }
23
+
24
+ applyTheme(theme) {
25
+ const root = document.documentElement
26
+ root.classList.remove("dark", "light")
27
+ root.classList.add(theme)
28
+ localStorage.setItem("rails_nexus-theme", theme)
29
+ }
30
+ }
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsNexusLoggedExceptions < ActiveRecord::Migration<%= "[#{ActiveRecord::Migration.current_version}]" %>
4
+ def change
5
+ create_table :rails_nexus_exceptions do |t|
6
+ t.string :exception_class
7
+ t.string :controller_name
8
+ t.string :action_name
9
+ t.text :message
10
+ t.text :backtrace
11
+ t.text :environment
12
+ t.text :request
13
+ t.string :user_info
14
+ t.string :user_agent
15
+ t.string :remote_ip
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :rails_nexus_exceptions, :created_at
21
+ add_index :rails_nexus_exceptions, :exception_class
22
+ add_index :rails_nexus_exceptions, [:controller_name, :action_name]
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ class AddAdvancedFeaturesToRailsNexusLoggedExceptions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Exception cause chain
4
+ add_column :rails_nexus_exceptions, :cause_chain, :text
5
+
6
+ # Breadcrumbs (activity trail before crash)
7
+ add_column :rails_nexus_exceptions, :breadcrumbs, :text
8
+
9
+ # System health snapshot at crash time
10
+ add_column :rails_nexus_exceptions, :system_health, :text
11
+
12
+ # User impact tracking
13
+ add_column :rails_nexus_exceptions, :user_id, :string
14
+ add_column :rails_nexus_exceptions, :user_type, :string
15
+
16
+ # Storm protection: occurrence count (deduplication)
17
+ add_column :rails_nexus_exceptions, :occurrence_count, :integer, default: 1
18
+
19
+ # Custom fingerprint for grouping
20
+ add_column :rails_nexus_exceptions, :fingerprint, :string
21
+
22
+ # Local/instance variables captured at raise time
23
+ add_column :rails_nexus_exceptions, :local_variables, :text
24
+ add_column :rails_nexus_exceptions, :instance_variables, :text
25
+
26
+ add_index :rails_nexus_exceptions, :user_id
27
+ add_index :rails_nexus_exceptions, :fingerprint
28
+ add_index :rails_nexus_exceptions, :occurrence_count
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ class CreateRailsNexusCronJobs < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :rails_nexus_cron_jobs do |t|
4
+ t.string :name, null: false
5
+ t.string :status, null: false, default: "pending"
6
+ t.text :output
7
+ t.text :error_message
8
+ t.float :duration
9
+ t.string :hostname
10
+ t.json :metadata
11
+ t.datetime :started_at
12
+ t.datetime :finished_at
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :rails_nexus_cron_jobs, :name
18
+ add_index :rails_nexus_cron_jobs, :status
19
+ add_index :rails_nexus_cron_jobs, :created_at
20
+ add_index :rails_nexus_cron_jobs, :started_at
21
+ end
22
+ end