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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +858 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/rails_error_dashboard/application.css +15 -0
- data/app/controllers/rails_error_dashboard/application_controller.rb +12 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +123 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/application_job.rb +4 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +116 -0
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +105 -0
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +166 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +108 -0
- data/app/mailers/rails_error_dashboard/application_mailer.rb +8 -0
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +27 -0
- data/app/models/rails_error_dashboard/application_record.rb +5 -0
- data/app/models/rails_error_dashboard/error_log.rb +185 -0
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/app/views/layouts/rails_error_dashboard/application.html.erb +17 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +351 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +200 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +32 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +237 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +334 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +294 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +40 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +13 -0
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +10 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +27 -0
- data/lib/generators/rails_error_dashboard/install/templates/README +33 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +64 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +40 -0
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +60 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +134 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +35 -0
- data/lib/rails_error_dashboard/configuration.rb +83 -0
- data/lib/rails_error_dashboard/engine.rb +20 -0
- data/lib/rails_error_dashboard/error_reporter.rb +35 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +41 -0
- data/lib/rails_error_dashboard/plugin.rb +98 -0
- data/lib/rails_error_dashboard/plugin_registry.rb +88 -0
- data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +96 -0
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +122 -0
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +78 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +108 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +37 -0
- data/lib/rails_error_dashboard/queries/developer_insights.rb +277 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +66 -0
- data/lib/rails_error_dashboard/queries/errors_list_v2.rb +149 -0
- data/lib/rails_error_dashboard/queries/filter_options.rb +21 -0
- data/lib/rails_error_dashboard/services/platform_detector.rb +41 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +148 -0
- data/lib/rails_error_dashboard/version.rb +3 -0
- data/lib/rails_error_dashboard.rb +60 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +4 -0
- 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,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>
|