rails_error_dashboard 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +858 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/rails_error_dashboard/application.css +15 -0
  6. data/app/controllers/rails_error_dashboard/application_controller.rb +12 -0
  7. data/app/controllers/rails_error_dashboard/errors_controller.rb +123 -0
  8. data/app/helpers/rails_error_dashboard/application_helper.rb +4 -0
  9. data/app/jobs/rails_error_dashboard/application_job.rb +4 -0
  10. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +116 -0
  11. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +19 -0
  12. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +105 -0
  13. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +166 -0
  14. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +108 -0
  15. data/app/mailers/rails_error_dashboard/application_mailer.rb +8 -0
  16. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +27 -0
  17. data/app/models/rails_error_dashboard/application_record.rb +5 -0
  18. data/app/models/rails_error_dashboard/error_log.rb +185 -0
  19. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  20. data/app/views/layouts/rails_error_dashboard/application.html.erb +17 -0
  21. data/app/views/layouts/rails_error_dashboard.html.erb +351 -0
  22. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +200 -0
  23. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +32 -0
  24. data/app/views/rails_error_dashboard/errors/analytics.html.erb +237 -0
  25. data/app/views/rails_error_dashboard/errors/index.html.erb +334 -0
  26. data/app/views/rails_error_dashboard/errors/show.html.erb +294 -0
  27. data/config/routes.rb +13 -0
  28. data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +40 -0
  29. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +13 -0
  30. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +10 -0
  31. data/lib/generators/rails_error_dashboard/install/install_generator.rb +27 -0
  32. data/lib/generators/rails_error_dashboard/install/templates/README +33 -0
  33. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +64 -0
  34. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +40 -0
  35. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +60 -0
  36. data/lib/rails_error_dashboard/commands/log_error.rb +134 -0
  37. data/lib/rails_error_dashboard/commands/resolve_error.rb +35 -0
  38. data/lib/rails_error_dashboard/configuration.rb +83 -0
  39. data/lib/rails_error_dashboard/engine.rb +20 -0
  40. data/lib/rails_error_dashboard/error_reporter.rb +35 -0
  41. data/lib/rails_error_dashboard/middleware/error_catcher.rb +41 -0
  42. data/lib/rails_error_dashboard/plugin.rb +98 -0
  43. data/lib/rails_error_dashboard/plugin_registry.rb +88 -0
  44. data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +96 -0
  45. data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +122 -0
  46. data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +78 -0
  47. data/lib/rails_error_dashboard/queries/analytics_stats.rb +108 -0
  48. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +37 -0
  49. data/lib/rails_error_dashboard/queries/developer_insights.rb +277 -0
  50. data/lib/rails_error_dashboard/queries/errors_list.rb +66 -0
  51. data/lib/rails_error_dashboard/queries/errors_list_v2.rb +149 -0
  52. data/lib/rails_error_dashboard/queries/filter_options.rb +21 -0
  53. data/lib/rails_error_dashboard/services/platform_detector.rb +41 -0
  54. data/lib/rails_error_dashboard/value_objects/error_context.rb +148 -0
  55. data/lib/rails_error_dashboard/version.rb +3 -0
  56. data/lib/rails_error_dashboard.rb +60 -0
  57. data/lib/tasks/rails_error_dashboard_tasks.rake +4 -0
  58. metadata +318 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ class ErrorNotificationMailer < ApplicationMailer
5
+ def error_alert(error_log, recipients)
6
+ @error_log = error_log
7
+ @dashboard_url = dashboard_url(error_log)
8
+
9
+ mail(
10
+ to: recipients,
11
+ subject: "🚨 [#{error_log.environment.upcase}] #{error_log.error_type}: #{truncate_subject(error_log.message)}"
12
+ )
13
+ end
14
+
15
+ private
16
+
17
+ def dashboard_url(error_log)
18
+ base_url = RailsErrorDashboard.configuration.dashboard_base_url || "http://localhost:3000"
19
+ "#{base_url}/error_dashboard/errors/#{error_log.id}"
20
+ end
21
+
22
+ def truncate_subject(message)
23
+ return "" unless message
24
+ message.length > 50 ? "#{message[0...50]}..." : message
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ module RailsErrorDashboard
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ class ErrorLog < ErrorLogsRecord
5
+ self.table_name = "rails_error_dashboard_error_logs"
6
+
7
+ # User association - works with both single and separate database
8
+ # When using separate database, joins are not possible, but Rails
9
+ # will automatically fetch users in a separate query when using includes()
10
+ # Only define association if User model exists
11
+ if defined?(::User)
12
+ belongs_to :user, optional: true
13
+ end
14
+
15
+ validates :error_type, presence: true
16
+ validates :message, presence: true
17
+ validates :environment, presence: true
18
+ validates :occurred_at, presence: true
19
+
20
+ scope :unresolved, -> { where(resolved: false) }
21
+ scope :resolved, -> { where(resolved: true) }
22
+ scope :recent, -> { order(occurred_at: :desc) }
23
+ scope :by_environment, ->(env) { where(environment: env) }
24
+ scope :by_error_type, ->(type) { where(error_type: type) }
25
+ scope :by_type, ->(type) { where(error_type: type) }
26
+ scope :by_platform, ->(platform) { where(platform: platform) }
27
+ scope :last_24_hours, -> { where("occurred_at >= ?", 24.hours.ago) }
28
+ scope :last_week, -> { where("occurred_at >= ?", 1.week.ago) }
29
+
30
+ # Set defaults and tracking
31
+ before_validation :set_defaults, on: :create
32
+ before_create :set_tracking_fields
33
+
34
+ def set_defaults
35
+ self.environment ||= Rails.env.to_s
36
+ self.platform ||= "API"
37
+ end
38
+
39
+ def set_tracking_fields
40
+ self.error_hash ||= generate_error_hash
41
+ self.first_seen_at ||= Time.current
42
+ self.last_seen_at ||= Time.current
43
+ self.occurrence_count ||= 1
44
+ end
45
+
46
+ # Generate unique hash for error grouping
47
+ # Includes controller/action for better context-aware grouping
48
+ def generate_error_hash
49
+ # Hash based on error class, normalized message, first stack frame, controller, and action
50
+ digest_input = [
51
+ error_type,
52
+ message&.gsub(/\d+/, "N")&.gsub(/"[^"]*"/, '""'), # Normalize numbers and strings
53
+ backtrace&.lines&.first&.split(":")&.first, # Just the file, not line number
54
+ controller_name, # Controller context
55
+ action_name # Action context
56
+ ].compact.join("|")
57
+
58
+ Digest::SHA256.hexdigest(digest_input)[0..15]
59
+ end
60
+
61
+ # Check if this is a critical error
62
+ def critical?
63
+ CRITICAL_ERROR_TYPES.include?(error_type)
64
+ end
65
+
66
+ # Check if error is recent (< 1 hour)
67
+ def recent?
68
+ occurred_at >= 1.hour.ago
69
+ end
70
+
71
+ # Check if error is old unresolved (> 7 days)
72
+ def stale?
73
+ !resolved? && occurred_at < 7.days.ago
74
+ end
75
+
76
+ # Get severity level
77
+ def severity
78
+ return :critical if CRITICAL_ERROR_TYPES.include?(error_type)
79
+ return :high if HIGH_SEVERITY_ERROR_TYPES.include?(error_type)
80
+ return :medium if MEDIUM_SEVERITY_ERROR_TYPES.include?(error_type)
81
+ :low
82
+ end
83
+
84
+ CRITICAL_ERROR_TYPES = %w[
85
+ SecurityError
86
+ NoMemoryError
87
+ SystemStackError
88
+ SignalException
89
+ ActiveRecord::StatementInvalid
90
+ ].freeze
91
+
92
+ HIGH_SEVERITY_ERROR_TYPES = %w[
93
+ ActiveRecord::RecordNotFound
94
+ ArgumentError
95
+ TypeError
96
+ NoMethodError
97
+ NameError
98
+ ].freeze
99
+
100
+ MEDIUM_SEVERITY_ERROR_TYPES = %w[
101
+ ActiveRecord::RecordInvalid
102
+ Timeout::Error
103
+ Net::ReadTimeout
104
+ Net::OpenTimeout
105
+ ].freeze
106
+
107
+ # Find existing error by hash or create new one
108
+ # This is CRITICAL for accurate occurrence tracking
109
+ def self.find_or_increment_by_hash(error_hash, attributes = {})
110
+ # Look for unresolved error with same hash in last 24 hours
111
+ # (resolved errors are considered "fixed" so new occurrence = new issue)
112
+ existing = unresolved
113
+ .where(error_hash: error_hash)
114
+ .where("occurred_at >= ?", 24.hours.ago)
115
+ .order(last_seen_at: :desc)
116
+ .first
117
+
118
+ if existing
119
+ # Increment existing error
120
+ existing.update!(
121
+ occurrence_count: existing.occurrence_count + 1,
122
+ last_seen_at: Time.current,
123
+ # Update context from latest occurrence
124
+ user_id: attributes[:user_id] || existing.user_id,
125
+ request_url: attributes[:request_url] || existing.request_url,
126
+ request_params: attributes[:request_params] || existing.request_params,
127
+ user_agent: attributes[:user_agent] || existing.user_agent,
128
+ ip_address: attributes[:ip_address] || existing.ip_address
129
+ )
130
+ existing
131
+ else
132
+ # Create new error record
133
+ # Ensure resolved has a value (default to false)
134
+ create!(attributes.reverse_merge(resolved: false))
135
+ end
136
+ end
137
+
138
+ # Log an error with context (delegates to Command)
139
+ def self.log_error(exception, context = {})
140
+ Commands::LogError.call(exception, context)
141
+ end
142
+
143
+ # Mark error as resolved (delegates to Command)
144
+ def resolve!(resolution_data = {})
145
+ Commands::ResolveError.call(id, resolution_data)
146
+ end
147
+
148
+ # Get error statistics
149
+ def self.statistics(days = 7)
150
+ start_date = days.days.ago
151
+
152
+ {
153
+ total: where("occurred_at >= ?", start_date).count,
154
+ unresolved: where("occurred_at >= ?", start_date).unresolved.count,
155
+ by_type: where("occurred_at >= ?", start_date)
156
+ .group(:error_type)
157
+ .count
158
+ .sort_by { |_, count| -count }
159
+ .to_h,
160
+ by_day: where("occurred_at >= ?", start_date)
161
+ .group("DATE(occurred_at)")
162
+ .count
163
+ }
164
+ end
165
+
166
+ # Find related errors of the same type
167
+ def related_errors(limit: 5)
168
+ self.class.where(error_type: error_type)
169
+ .where.not(id: id)
170
+ .order(occurred_at: :desc)
171
+ .limit(limit)
172
+ end
173
+
174
+ private
175
+
176
+ # Override user association to use configured user model
177
+ def self.belongs_to(*args, **options)
178
+ if args.first == :user
179
+ user_model = RailsErrorDashboard.configuration.user_model
180
+ options[:class_name] = user_model if user_model.present?
181
+ end
182
+ super
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Abstract base class for models stored in the error_logs database
4
+ #
5
+ # By default, this connects to the same database as the main application.
6
+ #
7
+ # To enable a separate error logs database:
8
+ # 1. Set use_separate_database: true in the gem configuration
9
+ # 2. Configure error_logs_database settings in config/database.yml
10
+ # 3. Run: rails db:create:error_logs
11
+ # 4. Run: rails db:migrate:error_logs
12
+ #
13
+ # Benefits of separate database:
14
+ # - Performance isolation (error logging doesn't slow down user requests)
15
+ # - Independent scaling (can put error DB on separate server)
16
+ # - Different retention policies (archive old errors without affecting main data)
17
+ # - Security isolation (different access controls for error logs)
18
+ #
19
+ # Trade-offs:
20
+ # - No foreign keys between error_logs and users tables
21
+ # - No joins across databases (Rails handles with separate queries)
22
+ # - Slightly more complex operations (need to manage 2 databases)
23
+
24
+ module RailsErrorDashboard
25
+ class ErrorLogsRecord < ActiveRecord::Base
26
+ self.abstract_class = true
27
+
28
+ # Connect to error_logs database (or primary if not using separate DB)
29
+ # Only connect to separate database if configuration is enabled
30
+ if RailsErrorDashboard.configuration&.use_separate_database
31
+ connects_to database: { writing: :error_logs, reading: :error_logs }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Rails error dashboard</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "rails_error_dashboard/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,351 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Error Dashboard - Audio Intelli API</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <!-- Bootstrap CSS -->
10
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
11
+ <!-- Bootstrap Icons -->
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
13
+ <!-- Chart.js with date adapter -->
14
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
16
+ <script src="https://cdn.jsdelivr.net/npm/chartkick@5.0.1/dist/chartkick.min.js"></script>
17
+
18
+ <style>
19
+ :root {
20
+ --primary-color: #8B5CF6;
21
+ --success-color: #10B981;
22
+ --danger-color: #EF4444;
23
+ --warning-color: #F59E0B;
24
+ --info-color: #3B82F6;
25
+ }
26
+
27
+ /* Light mode (default) */
28
+ body {
29
+ --bg-color: #F3F4F6;
30
+ --text-color: #1F2937;
31
+ --card-bg: #FFFFFF;
32
+ --sidebar-bg: #FFFFFF;
33
+ --sidebar-hover: #F9FAFB;
34
+ --border-color: #E5E7EB;
35
+ --table-hover: #F9FAFB;
36
+ --nav-active-bg: #EDE9FE;
37
+ background-color: var(--bg-color);
38
+ color: var(--text-color);
39
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
40
+ transition: background-color 0.3s ease, color 0.3s ease;
41
+ }
42
+
43
+ /* Dark mode */
44
+ body.dark-mode {
45
+ --bg-color: #1A1B26;
46
+ --text-color: #E4E5E9;
47
+ --card-bg: #24283B;
48
+ --sidebar-bg: #1F2130;
49
+ --sidebar-hover: #2A2D3E;
50
+ --border-color: #414868;
51
+ --table-hover: #2A2D3E;
52
+ --nav-active-bg: #2A2D3E;
53
+ }
54
+
55
+ .card {
56
+ background-color: var(--card-bg);
57
+ color: var(--text-color);
58
+ border-color: var(--border-color);
59
+ }
60
+
61
+ .card-header {
62
+ background-color: var(--card-bg) !important;
63
+ color: var(--text-color);
64
+ border-color: var(--border-color);
65
+ }
66
+
67
+ .navbar {
68
+ background: linear-gradient(135deg, var(--primary-color), #6D28D9);
69
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
70
+ }
71
+
72
+ .sidebar {
73
+ background: var(--sidebar-bg);
74
+ min-height: calc(100vh - 56px);
75
+ box-shadow: 2px 0 4px rgba(0,0,0,0.05);
76
+ }
77
+
78
+ .sidebar .nav-link {
79
+ color: var(--text-color);
80
+ padding: 0.75rem 1.5rem;
81
+ border-left: 3px solid transparent;
82
+ transition: all 0.2s;
83
+ }
84
+
85
+ .sidebar .nav-link:hover {
86
+ background-color: var(--sidebar-hover);
87
+ color: var(--primary-color);
88
+ border-left-color: var(--primary-color);
89
+ }
90
+
91
+ .sidebar .nav-link.active {
92
+ background-color: var(--nav-active-bg);
93
+ color: var(--primary-color);
94
+ border-left-color: var(--primary-color);
95
+ font-weight: 600;
96
+ }
97
+
98
+ .stat-card {
99
+ border-radius: 0.75rem;
100
+ border: none;
101
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
102
+ transition: transform 0.2s, box-shadow 0.2s;
103
+ }
104
+
105
+ .stat-card:hover {
106
+ transform: translateY(-2px);
107
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
108
+ }
109
+
110
+ .stat-value {
111
+ font-size: 2rem;
112
+ font-weight: 700;
113
+ }
114
+
115
+ .stat-label {
116
+ color: #6B7280;
117
+ font-size: 0.875rem;
118
+ text-transform: uppercase;
119
+ letter-spacing: 0.05em;
120
+ }
121
+
122
+ .badge-ios {
123
+ background-color: #000000;
124
+ }
125
+
126
+ .badge-android {
127
+ background-color: #3DDC84;
128
+ color: #000;
129
+ }
130
+
131
+ .badge-api {
132
+ background-color: var(--info-color);
133
+ }
134
+
135
+ .table {
136
+ color: var(--text-color);
137
+ background-color: var(--card-bg);
138
+ }
139
+
140
+ .table thead {
141
+ background-color: var(--sidebar-hover);
142
+ color: var(--text-color);
143
+ }
144
+
145
+ .table tbody {
146
+ background-color: var(--card-bg);
147
+ }
148
+
149
+ .table-hover tbody tr {
150
+ background-color: var(--card-bg);
151
+ }
152
+
153
+ .table-hover tbody tr:hover {
154
+ background-color: var(--table-hover) !important;
155
+ cursor: pointer;
156
+ }
157
+
158
+ .table-light {
159
+ background-color: var(--sidebar-hover) !important;
160
+ color: var(--text-color) !important;
161
+ }
162
+
163
+ /* Fix table cells in dark mode */
164
+ .table td, .table th {
165
+ background-color: var(--card-bg) !important;
166
+ color: var(--text-color) !important;
167
+ border-color: var(--border-color) !important;
168
+ }
169
+
170
+ .table thead th {
171
+ background-color: var(--sidebar-hover) !important;
172
+ }
173
+
174
+ .form-control, .form-select {
175
+ background-color: var(--card-bg);
176
+ color: var(--text-color);
177
+ border-color: var(--border-color);
178
+ }
179
+
180
+ .form-control:focus, .form-select:focus {
181
+ background-color: var(--card-bg);
182
+ color: var(--text-color);
183
+ border-color: var(--primary-color);
184
+ }
185
+
186
+ .modal-content {
187
+ background-color: var(--card-bg);
188
+ color: var(--text-color);
189
+ }
190
+
191
+ .modal-header, .modal-footer {
192
+ border-color: var(--border-color);
193
+ }
194
+
195
+ .btn-close {
196
+ filter: brightness(0) invert(1);
197
+ }
198
+
199
+ body.dark-mode .btn-close {
200
+ filter: brightness(0) invert(1);
201
+ }
202
+
203
+ .text-muted {
204
+ color: #9CA3AF !important;
205
+ }
206
+
207
+ .theme-toggle {
208
+ cursor: pointer;
209
+ padding: 0.5rem 1rem;
210
+ border-radius: 0.5rem;
211
+ background-color: rgba(255, 255, 255, 0.1);
212
+ color: white;
213
+ border: none;
214
+ transition: background-color 0.2s;
215
+ }
216
+
217
+ .theme-toggle:hover {
218
+ background-color: rgba(255, 255, 255, 0.2);
219
+ }
220
+
221
+ .error-status-resolved {
222
+ color: var(--success-color);
223
+ }
224
+
225
+ .error-status-unresolved {
226
+ color: var(--danger-color);
227
+ }
228
+
229
+ .chart-container {
230
+ position: relative;
231
+ height: 300px;
232
+ margin: 1rem 0;
233
+ }
234
+ </style>
235
+ </head>
236
+
237
+ <body>
238
+ <!-- Top Navbar -->
239
+ <nav class="navbar navbar-dark">
240
+ <div class="container-fluid">
241
+ <a class="navbar-brand fw-bold" href="<%= root_path %>">
242
+ <i class="bi bi-bug-fill"></i>
243
+ Error Dashboard
244
+ </a>
245
+ <div class="d-flex align-items-center gap-3">
246
+ <button class="theme-toggle" id="themeToggle" onclick="toggleTheme()">
247
+ <i class="bi bi-moon-fill" id="themeIcon"></i>
248
+ </button>
249
+ <div class="text-white">
250
+ <small><%= Rails.env.titleize %> Environment</small>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </nav>
255
+
256
+ <div class="container-fluid">
257
+ <div class="row">
258
+ <!-- Sidebar -->
259
+ <nav class="col-md-2 d-md-block sidebar">
260
+ <div class="position-sticky pt-3">
261
+ <ul class="nav flex-column">
262
+ <li class="nav-item">
263
+ <%= link_to root_path, class: "nav-link #{request.path == root_path ? 'active' : ''}" do %>
264
+ <i class="bi bi-speedometer2"></i> Overview
265
+ <% end %>
266
+ </li>
267
+ <li class="nav-item">
268
+ <%= link_to errors_path, class: "nav-link #{request.path == errors_path ? 'active' : ''}" do %>
269
+ <i class="bi bi-list-ul"></i> All Errors
270
+ <% end %>
271
+ </li>
272
+ <li class="nav-item">
273
+ <%= link_to analytics_errors_path, class: "nav-link #{request.path == analytics_errors_path ? 'active' : ''}" do %>
274
+ <i class="bi bi-graph-up"></i> Analytics
275
+ <% end %>
276
+ </li>
277
+ </ul>
278
+
279
+ <hr class="my-3">
280
+
281
+ <h6 class="sidebar-heading px-3 mt-4 mb-2 text-muted text-uppercase">
282
+ <small>Quick Filters</small>
283
+ </h6>
284
+ <ul class="nav flex-column">
285
+ <li class="nav-item">
286
+ <%= link_to errors_path(unresolved: true), class: "nav-link" do %>
287
+ <i class="bi bi-exclamation-circle text-danger"></i> Unresolved
288
+ <% end %>
289
+ </li>
290
+ <li class="nav-item">
291
+ <%= link_to errors_path(platform: 'iOS'), class: "nav-link" do %>
292
+ <i class="bi bi-phone"></i> iOS Errors
293
+ <% end %>
294
+ </li>
295
+ <li class="nav-item">
296
+ <%= link_to errors_path(platform: 'Android'), class: "nav-link" do %>
297
+ <i class="bi bi-phone"></i> Android Errors
298
+ <% end %>
299
+ </li>
300
+ <li class="nav-item">
301
+ <%= link_to errors_path(environment: 'production'), class: "nav-link" do %>
302
+ <i class="bi bi-server"></i> Production
303
+ <% end %>
304
+ </li>
305
+ </ul>
306
+ </div>
307
+ </nav>
308
+
309
+ <!-- Main content -->
310
+ <main class="col-md-10 ms-sm-auto px-md-4">
311
+ <%= yield %>
312
+ </main>
313
+ </div>
314
+ </div>
315
+
316
+ <!-- Bootstrap JS -->
317
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
318
+
319
+ <!-- Theme Toggle Script -->
320
+ <script>
321
+ // Load theme from localStorage on page load
322
+ document.addEventListener('DOMContentLoaded', function() {
323
+ const savedTheme = localStorage.getItem('theme') || 'light';
324
+ if (savedTheme === 'dark') {
325
+ document.body.classList.add('dark-mode');
326
+ updateThemeIcon(true);
327
+ }
328
+ });
329
+
330
+ function toggleTheme() {
331
+ const body = document.body;
332
+ const isDark = body.classList.toggle('dark-mode');
333
+
334
+ // Save preference
335
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
336
+
337
+ // Update icon
338
+ updateThemeIcon(isDark);
339
+ }
340
+
341
+ function updateThemeIcon(isDark) {
342
+ const icon = document.getElementById('themeIcon');
343
+ if (isDark) {
344
+ icon.className = 'bi bi-sun-fill';
345
+ } else {
346
+ icon.className = 'bi bi-moon-fill';
347
+ }
348
+ }
349
+ </script>
350
+ </body>
351
+ </html>