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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RailsNexus
6
+ class BackupService
7
+ attr_reader :config_path, :models_path, :dump_path
8
+
9
+ CONFIG_FILE = "rails_nexus_backup.yml"
10
+
11
+ def initialize
12
+ saved = self.class.load_config
13
+ @config_path = saved[:config_path] || RailsNexus.configuration.backup_config_path || default_config_path
14
+ @models_path = saved[:models_path] || RailsNexus.configuration.backup_models_path || default_models_path
15
+ @dump_path = saved[:dump_path] || RailsNexus.configuration.backup_dump_path || default_dump_path
16
+ end
17
+
18
+ # Load saved config from YAML file
19
+ def self.load_config
20
+ path = config_file_path
21
+ return default_config unless File.exist?(path)
22
+
23
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol, Date, Time]) || {}
24
+ default_config.merge(data.transform_keys(&:to_sym))
25
+ rescue StandardError
26
+ default_config
27
+ end
28
+
29
+ # Save config to YAML file
30
+ def self.save_config(params)
31
+ config = load_config
32
+
33
+ # Merge new params
34
+ params.each do |key, value|
35
+ config[key.to_sym] = value
36
+ end
37
+
38
+ # Ensure directory exists
39
+ dir = File.dirname(config_file_path)
40
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
41
+
42
+ # Write YAML
43
+ File.write(config_file_path, config.to_yaml)
44
+
45
+ { success: true }
46
+ rescue StandardError => e
47
+ { success: false, error: e.message }
48
+ end
49
+
50
+ # Delete saved config
51
+ def self.reset_config
52
+ File.delete(config_file_path) if File.exist?(config_file_path)
53
+ { success: true }
54
+ rescue StandardError => e
55
+ { success: false, error: e.message }
56
+ end
57
+
58
+ # Check if backup gem is configured
59
+ def configured?
60
+ File.exist?(@config_path) && Dir.exist?(@models_path)
61
+ end
62
+
63
+ # Get all backup models
64
+ def models
65
+ return [] unless configured?
66
+
67
+ Dir.glob(File.join(@models_path, "*.rb")).map do |path|
68
+ parse_model_file(path)
69
+ end.compact
70
+ end
71
+
72
+ # Get backup files
73
+ def backup_files
74
+ return [] unless @dump_path && Dir.exist?(@dump_path)
75
+
76
+ Dir.glob(File.join(@dump_path, "*")).select { |f| File.file?(f) }.map do |path|
77
+ {
78
+ name: File.basename(path),
79
+ path: path,
80
+ size: File.size(path),
81
+ size_human: human_size(File.size(path)),
82
+ created_at: File.mtime(path),
83
+ age_hours: ((Time.current - File.mtime(path)) / 3600).round(1),
84
+ encrypted: path.end_with?(".enc"),
85
+ compressed: path.end_with?(".gz") || path.end_with?(".tar.gz")
86
+ }
87
+ end.sort_by { |f| -f[:created_at].to_i }
88
+ end
89
+
90
+ # Get backup summary stats
91
+ def summary
92
+ files = backup_files
93
+ {
94
+ total_files: files.size,
95
+ total_size: files.sum { |f| f[:size] },
96
+ total_size_human: human_size(files.sum { |f| f[:size] }),
97
+ last_backup: files.first&.dig(:created_at),
98
+ last_backup_age_hours: files.first&.dig(:age_hours),
99
+ oldest_backup: files.last&.dig(:created_at),
100
+ models_count: models.size,
101
+ healthy: last_backup_healthy?,
102
+ alerts: generate_alerts
103
+ }
104
+ end
105
+
106
+ # Get cron schedule from whenever config
107
+ def cron_schedule
108
+ schedule_file = File.join(File.dirname(@config_path), "config", "schedule.rb")
109
+ return nil unless File.exist?(schedule_file)
110
+
111
+ content = File.read(schedule_file)
112
+ {
113
+ file: schedule_file,
114
+ entries: parse_schedule(content),
115
+ raw: content
116
+ }
117
+ end
118
+
119
+ # Run a backup model
120
+ def trigger_backup(model_name)
121
+ return { success: false, error: "Backup not configured" } unless configured?
122
+ return { success: false, error: "Invalid model: #{model_name}" } unless valid_model?(model_name)
123
+
124
+ runner = find_runner_script
125
+ return { success: false, error: "Runner script not found" } unless runner
126
+
127
+ log_file = File.join(File.dirname(@config_path), "log", "cron.log")
128
+ error_log = File.join(File.dirname(@config_path), "log", "cron-error.log")
129
+
130
+ command = "bash #{runner} #{model_name} >> #{log_file} 2>> #{error_log}"
131
+ pid = Process.spawn(command)
132
+ Process.detach(pid)
133
+
134
+ { success: true, pid: pid, model: model_name }
135
+ end
136
+
137
+ # Get backup health status
138
+ def health_status
139
+ return { status: "not_configured", message: "Backup not configured" } unless configured?
140
+
141
+ summary_data = summary
142
+ threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
143
+
144
+ if summary_data[:last_backup].nil?
145
+ { status: "warning", message: "No backups found", details: summary_data }
146
+ elsif summary_data[:last_backup_age_hours] > threshold * 2
147
+ { status: "critical", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold * 2}h)", details: summary_data }
148
+ elsif summary_data[:last_backup_age_hours] > threshold
149
+ { status: "warning", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold}h)", details: summary_data }
150
+ else
151
+ { status: "healthy", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago", details: summary_data }
152
+ end
153
+ end
154
+
155
+ # Get saved config
156
+ def saved_config
157
+ @saved_config ||= self.class.load_config
158
+ end
159
+
160
+ private
161
+
162
+ def self.config_file_path
163
+ Rails.root.join("config", CONFIG_FILE)
164
+ end
165
+
166
+ def self.default_config
167
+ {
168
+ enabled: true,
169
+ config_path: nil,
170
+ models_path: nil,
171
+ dump_path: nil,
172
+ notify_command: nil,
173
+ alert_threshold_hours: 24,
174
+ rsync_host: nil,
175
+ rsync_port: 22,
176
+ rsync_user: nil,
177
+ rsync_path: nil,
178
+ encrypt_password: nil,
179
+ auto_cleanup_days: 30
180
+ }
181
+ end
182
+
183
+ def default_config_path
184
+ Rails.root.join("config", "backup", "config.rb").to_s
185
+ end
186
+
187
+ def default_models_path
188
+ Rails.root.join("config", "backup", "models").to_s
189
+ end
190
+
191
+ def default_dump_path
192
+ Rails.root.join("storage", "rails_nexus", "backups").to_s
193
+ end
194
+
195
+ def parse_model_file(path)
196
+ content = File.read(path)
197
+ name_match = content.match(/Model\.new\(:(\w+)/)
198
+ desc_match = content.match(/'([^']+)'/)
199
+
200
+ return nil unless name_match
201
+
202
+ {
203
+ name: name_match[1],
204
+ description: desc_match ? desc_match[1] : name_match[1],
205
+ file: path,
206
+ has_mysql: content.include?("database MySQL"),
207
+ has_local: content.include?("store_with Local"),
208
+ has_rsync: content.include?("sync_with RSync"),
209
+ has_encryption: content.include?("encrypt_with OpenSSL"),
210
+ has_compression: content.include?("compress_with Gzip"),
211
+ skip_tables: content.scan(/skip_tables\s*=\s*\[([^\]]+)\]/).flatten.first,
212
+ keep_count: content.scan(/keep\s*=\s*(\d+)/).flatten.first&.to_i
213
+ }
214
+ end
215
+
216
+ def parse_schedule(content)
217
+ entries = []
218
+ content.scan(/every\s+([^,]+)(?:,\s*at:\s*"([^"]+)")?\s+do\s+command\s+"([^"]+)"/) do |match|
219
+ entries << {
220
+ frequency: match[0].strip,
221
+ time: match[1],
222
+ command: match[2]
223
+ }
224
+ end
225
+ entries
226
+ end
227
+
228
+ def find_runner_script
229
+ runner = File.join(File.dirname(@config_path), "bin", "run-backup")
230
+ runner if File.exist?(runner)
231
+ end
232
+
233
+ def valid_model?(name)
234
+ models.any? { |m| m[:name] == name }
235
+ end
236
+
237
+ def last_backup_healthy?
238
+ files = backup_files
239
+ return false if files.empty?
240
+
241
+ threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
242
+ files.first[:age_hours] <= threshold
243
+ end
244
+
245
+ def generate_alerts
246
+ alerts = []
247
+ files = backup_files
248
+ threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
249
+
250
+ if files.empty?
251
+ alerts << { level: "warning", message: "No backup files found" }
252
+ elsif files.first[:age_hours] > threshold
253
+ alerts << { level: "critical", message: "No backup in #{files.first[:age_hours]}h (threshold: #{threshold}h)" }
254
+ end
255
+
256
+ # Check for old backups consuming space
257
+ old_files = files.select { |f| f[:age_hours] > 720 } # 30 days
258
+ if old_files.size > 10
259
+ alerts << { level: "info", message: "#{old_files.size} backups older than 30 days (#{human_size(old_files.sum { |f| f[:size] })})" }
260
+ end
261
+
262
+ alerts
263
+ end
264
+
265
+ def human_size(bytes)
266
+ return "0 B" if bytes == 0
267
+
268
+ units = %w[B KB MB GB TB]
269
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
270
+ exp = units.size - 1 if exp >= units.size
271
+
272
+ "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,185 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full" data-controller="rails_nexus-theme" data-rails_nexus-theme-theme-value="<%= RailsNexus.configuration.theme %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title><%= content_for?(:title) ? yield(:title) : RailsNexus.configuration.application_name || "RailsNexus" %></title>
8
+
9
+ <%= stylesheet_link_tag "rails_nexus/application", "data-turbo-track": "reload" %>
10
+
11
+ <%= javascript_importmap_tags "rails_nexus/application" %>
12
+
13
+ <% if respond_to?(:turbo_refreshes_with) %>
14
+ <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
15
+ <% end %>
16
+ <%= csrf_meta_tags %>
17
+ <%= csp_meta_tag %>
18
+ </head>
19
+ <body class="h-full <%= RailsNexus.configuration.body_class %>">
20
+ <div class="min-h-full flex">
21
+
22
+ <%# Sidebar %>
23
+ <aside class="rn-sidebar">
24
+ <%# Logo %>
25
+ <div class="rn-sidebar-logo">
26
+ <%= link_to root_path, class: "flex items-center gap-2.5" do %>
27
+ <div class="rn-logo-icon">⚡</div>
28
+ <span class="rn-logo-text"><%= RailsNexus.configuration.application_name || "RailsNexus" %></span>
29
+ <% end %>
30
+ </div>
31
+
32
+ <%# Navigation %>
33
+ <nav class="rn-sidebar-nav">
34
+ <%= link_to root_path,
35
+ class: "rn-nav-item #{request.path == rails_nexus.root_path ? 'active' : ''}" do %>
36
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /></svg>
37
+ Dashboard
38
+ <% end %>
39
+
40
+ <%= link_to database_health_path,
41
+ class: "rn-nav-item #{request.path == rails_nexus.database_health_path ? 'active' : ''}" do %>
42
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>
43
+ DB Health
44
+ <% end %>
45
+
46
+ <%= link_to n1_patterns_path,
47
+ class: "rn-nav-item #{request.path == rails_nexus.n1_patterns_path ? 'active' : ''}" do %>
48
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg>
49
+ N+1 Queries
50
+ <% end %>
51
+
52
+ <%= link_to analytics_path,
53
+ class: "rn-nav-item #{request.path == rails_nexus.analytics_path ? 'active' : ''}" do %>
54
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5" /></svg>
55
+ Analytics
56
+ <% end %>
57
+
58
+ <%= link_to stats_path,
59
+ class: "rn-nav-item #{request.path == rails_nexus.stats_path ? 'active' : ''}" do %>
60
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg>
61
+ Statistics
62
+ <% end %>
63
+
64
+ <%= link_to cron_jobs_path,
65
+ class: "rn-nav-item #{request.path.start_with?(rails_nexus.cron_jobs_path) ? 'active' : ''}" do %>
66
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
67
+ Cron Jobs
68
+ <% end %>
69
+
70
+ <%= link_to backup_path,
71
+ class: "rn-nav-item #{request.path.start_with?(rails_nexus.backup_path) ? 'active' : ''}" do %>
72
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>
73
+ Backups
74
+ <% end %>
75
+
76
+ <%= link_to settings_path,
77
+ class: "rn-nav-item #{request.path == rails_nexus.settings_path ? 'active' : ''}" do %>
78
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
79
+ Settings
80
+ <% end %>
81
+
82
+ <%# ─── Tools section ──────────────────────────────────────── %>
83
+ <div class="rn-sidebar-section-divider"></div>
84
+
85
+ <%= link_to feed_logged_exceptions_path(format: :rss),
86
+ class: "rn-nav-item rn-nav-item--muted" do %>
87
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 00-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /></svg>
88
+ RSS Feed
89
+ <% end %>
90
+
91
+ <% RailsNexus.configuration.sidebar_links.each do |link| %>
92
+ <%= link_to link[:url],
93
+ class: "rn-nav-item",
94
+ target: link[:target] || "_self" do %>
95
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
96
+ <%= link[:label] %>
97
+ <% end %>
98
+ <% end %>
99
+ </nav>
100
+
101
+ <%# Footer %>
102
+ <div class="rn-sidebar-footer">
103
+ v<%= RailsNexus::VERSION %> · <%= RailsNexus::LoggedException.count %> exceptions
104
+ </div>
105
+ </aside>
106
+
107
+ <%# Main Content %>
108
+ <div class="flex-1 flex flex-col min-w-0">
109
+ <%# Top Bar %>
110
+ <header class="rn-topbar">
111
+ <span class="text-sm font-semibold"><%= RailsNexus.configuration.application_name || "RailsNexus" %></span>
112
+
113
+ <div class="flex items-center gap-1.5">
114
+ <% if RailsNexus.configuration.keyboard_shortcuts %>
115
+ <span class="text-xs flex items-center gap-1" style="color: var(--rn-text-muted)">
116
+ <span class="rn-kbd">?</span>
117
+ </span>
118
+ <% end %>
119
+
120
+ <button type="button"
121
+ class="rn-icon-btn"
122
+ data-action="click->rails_nexus-theme#toggle"
123
+ title="Toggle theme (t)">
124
+ <svg class="w-4 h-4 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /></svg>
125
+ <svg class="w-4 h-4 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /></svg>
126
+ </button>
127
+
128
+ <% if main_app.respond_to?(:root_path) %>
129
+ <%= link_to main_app.root_path,
130
+ class: "rn-icon-btn",
131
+ title: "Back to app" do %>
132
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
133
+ <% end %>
134
+ <% end %>
135
+ </div>
136
+ </header>
137
+
138
+ <%# Flash Messages %>
139
+ <% if notice.present? %>
140
+ <div class="mx-4 sm:mx-6 mt-4">
141
+ <div class="rn-alert rn-alert-success">
142
+ <svg class="w-5 h-5 shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>
143
+ <p class="text-sm font-medium"><%= notice %></p>
144
+ </div>
145
+ </div>
146
+ <% end %>
147
+
148
+ <% if alert.present? %>
149
+ <div class="mx-4 sm:mx-6 mt-4">
150
+ <div class="rn-alert rn-alert-error">
151
+ <svg class="w-5 h-5 shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" /></svg>
152
+ <p class="text-sm font-medium"><%= alert %></p>
153
+ </div>
154
+ </div>
155
+ <% end %>
156
+
157
+ <%# Main Content %>
158
+ <main class="flex-1 overflow-y-auto p-4 sm:p-6">
159
+ <%= yield %>
160
+ </main>
161
+ </div>
162
+ </div>
163
+
164
+ <%# Mobile sidebar overlay %>
165
+ <div class="rn-overlay" data-rails_nexus-sidebar-target="overlay" data-action="click->rails_nexus-sidebar#close"></div>
166
+
167
+ <%# Mobile sidebar drawer %>
168
+ <div class="rn-mobile-sidebar" data-rails_nexus-sidebar-target="drawer">
169
+ <div class="rn-sidebar-logo">
170
+ <span class="rn-logo-text"><%= RailsNexus.configuration.application_name || "RailsNexus" %></span>
171
+ <button type="button" class="rn-icon-btn" data-action="click->rails_nexus-sidebar#close">
172
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
173
+ </button>
174
+ </div>
175
+ <nav class="rn-sidebar-nav">
176
+ <%= link_to root_path, class: "rn-nav-item" do %>
177
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6z" /></svg>
178
+ Dashboard
179
+ <% end %>
180
+ </nav>
181
+ </div>
182
+
183
+ <%= turbo_frame_tag :remote_modal, target: :_top %>
184
+ </body>
185
+ </html>