findbug 0.2.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +375 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/findbug/application_controller.rb +105 -0
  8. data/app/controllers/findbug/dashboard_controller.rb +93 -0
  9. data/app/controllers/findbug/errors_controller.rb +129 -0
  10. data/app/controllers/findbug/performance_controller.rb +80 -0
  11. data/app/jobs/findbug/alert_job.rb +40 -0
  12. data/app/jobs/findbug/cleanup_job.rb +132 -0
  13. data/app/jobs/findbug/persist_job.rb +158 -0
  14. data/app/models/findbug/error_event.rb +197 -0
  15. data/app/models/findbug/performance_event.rb +237 -0
  16. data/app/views/findbug/dashboard/index.html.erb +199 -0
  17. data/app/views/findbug/errors/index.html.erb +137 -0
  18. data/app/views/findbug/errors/show.html.erb +185 -0
  19. data/app/views/findbug/performance/index.html.erb +168 -0
  20. data/app/views/findbug/performance/show.html.erb +203 -0
  21. data/app/views/layouts/findbug/application.html.erb +601 -0
  22. data/lib/findbug/alerts/channels/base.rb +75 -0
  23. data/lib/findbug/alerts/channels/discord.rb +155 -0
  24. data/lib/findbug/alerts/channels/email.rb +179 -0
  25. data/lib/findbug/alerts/channels/slack.rb +149 -0
  26. data/lib/findbug/alerts/channels/webhook.rb +143 -0
  27. data/lib/findbug/alerts/dispatcher.rb +126 -0
  28. data/lib/findbug/alerts/throttler.rb +110 -0
  29. data/lib/findbug/background_persister.rb +142 -0
  30. data/lib/findbug/capture/context.rb +301 -0
  31. data/lib/findbug/capture/exception_handler.rb +141 -0
  32. data/lib/findbug/capture/exception_subscriber.rb +228 -0
  33. data/lib/findbug/capture/message_handler.rb +104 -0
  34. data/lib/findbug/capture/middleware.rb +247 -0
  35. data/lib/findbug/configuration.rb +381 -0
  36. data/lib/findbug/engine.rb +109 -0
  37. data/lib/findbug/performance/instrumentation.rb +336 -0
  38. data/lib/findbug/performance/transaction.rb +193 -0
  39. data/lib/findbug/processing/data_scrubber.rb +163 -0
  40. data/lib/findbug/rails/controller_methods.rb +152 -0
  41. data/lib/findbug/railtie.rb +222 -0
  42. data/lib/findbug/storage/circuit_breaker.rb +223 -0
  43. data/lib/findbug/storage/connection_pool.rb +134 -0
  44. data/lib/findbug/storage/redis_buffer.rb +285 -0
  45. data/lib/findbug/tasks/findbug.rake +167 -0
  46. data/lib/findbug/version.rb +5 -0
  47. data/lib/findbug.rb +216 -0
  48. data/lib/generators/findbug/install_generator.rb +67 -0
  49. data/lib/generators/findbug/templates/POST_INSTALL +41 -0
  50. data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
  51. data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
  52. data/lib/generators/findbug/templates/initializer.rb +157 -0
  53. data/sig/findbug.rbs +4 -0
  54. metadata +251 -0
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ # ErrorEvent stores captured exceptions in the database.
5
+ #
6
+ # DATABASE SCHEMA
7
+ # ===============
8
+ #
9
+ # This model expects a table created by the install generator:
10
+ #
11
+ # create_table :findbug_error_events do |t|
12
+ # t.string :fingerprint, null: false
13
+ # t.string :exception_class, null: false
14
+ # t.text :message
15
+ # t.text :backtrace
16
+ # t.jsonb :context, default: {}
17
+ # t.jsonb :request_data, default: {}
18
+ # t.string :environment
19
+ # t.string :release_version
20
+ # t.string :severity, default: 'error'
21
+ # t.string :source
22
+ # t.boolean :handled, default: false
23
+ # t.integer :occurrence_count, default: 1
24
+ # t.datetime :first_seen_at
25
+ # t.datetime :last_seen_at
26
+ # t.string :status, default: 'unresolved'
27
+ # t.timestamps
28
+ # end
29
+ #
30
+ # WHY JSONB FOR CONTEXT?
31
+ # ======================
32
+ #
33
+ # Context is semi-structured - different errors have different context.
34
+ # JSONB (in PostgreSQL) or JSON (in other DBs) lets us store any shape
35
+ # of data without schema migrations.
36
+ #
37
+ # For querying, we create GIN indexes on commonly queried paths.
38
+ #
39
+ class ErrorEvent < ActiveRecord::Base
40
+ self.table_name = "findbug_error_events"
41
+
42
+ # Statuses
43
+ STATUS_UNRESOLVED = "unresolved"
44
+ STATUS_RESOLVED = "resolved"
45
+ STATUS_IGNORED = "ignored"
46
+
47
+ # Severities
48
+ SEVERITY_ERROR = "error"
49
+ SEVERITY_WARNING = "warning"
50
+ SEVERITY_INFO = "info"
51
+
52
+ # Validations
53
+ validates :fingerprint, presence: true
54
+ validates :exception_class, presence: true
55
+ validates :status, inclusion: { in: [STATUS_UNRESOLVED, STATUS_RESOLVED, STATUS_IGNORED] }
56
+ validates :severity, inclusion: { in: [SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO] }
57
+
58
+ # Scopes
59
+ scope :unresolved, -> { where(status: STATUS_UNRESOLVED) }
60
+ scope :resolved, -> { where(status: STATUS_RESOLVED) }
61
+ scope :ignored, -> { where(status: STATUS_IGNORED) }
62
+ scope :errors, -> { where(severity: SEVERITY_ERROR) }
63
+ scope :warnings, -> { where(severity: SEVERITY_WARNING) }
64
+ scope :recent, -> { order(last_seen_at: :desc) }
65
+ scope :by_occurrence, -> { order(occurrence_count: :desc) }
66
+
67
+ # Time-based scopes
68
+ scope :last_24_hours, -> { where("last_seen_at >= ?", 24.hours.ago) }
69
+ scope :last_7_days, -> { where("last_seen_at >= ?", 7.days.ago) }
70
+ scope :last_30_days, -> { where("last_seen_at >= ?", 30.days.ago) }
71
+
72
+ # Find or create an error event, incrementing count if exists
73
+ #
74
+ # @param event_data [Hash] the error event data from Redis
75
+ # @return [ErrorEvent] the created or updated error event
76
+ #
77
+ # UPSERT PATTERN
78
+ # ==============
79
+ #
80
+ # We use "upsert" logic: if an error with this fingerprint exists,
81
+ # we update it (increment count, update last_seen_at). Otherwise,
82
+ # we create a new record.
83
+ #
84
+ # This groups similar errors together instead of creating thousands
85
+ # of duplicate records.
86
+ #
87
+ def self.upsert_from_event(event_data)
88
+ fingerprint = event_data[:fingerprint]
89
+
90
+ # Use database-level locking to prevent race conditions
91
+ transaction do
92
+ existing = find_by(fingerprint: fingerprint)
93
+
94
+ if existing
95
+ # Update existing error
96
+ existing.occurrence_count += 1
97
+ existing.last_seen_at = Time.current
98
+
99
+ # Update context with latest (might have new info)
100
+ existing.context = merge_contexts(existing.context, event_data[:context])
101
+
102
+ # If it was resolved but happened again, reopen it
103
+ if existing.status == STATUS_RESOLVED
104
+ existing.status = STATUS_UNRESOLVED
105
+ end
106
+
107
+ existing.save!
108
+ existing
109
+ else
110
+ # Create new error
111
+ create!(
112
+ fingerprint: fingerprint,
113
+ exception_class: event_data[:exception_class],
114
+ message: event_data[:message],
115
+ backtrace: serialize_backtrace(event_data[:backtrace]),
116
+ context: event_data[:context] || {},
117
+ request_data: event_data[:context]&.dig(:request) || {},
118
+ environment: event_data[:environment],
119
+ release_version: event_data[:release],
120
+ severity: event_data[:severity] || SEVERITY_ERROR,
121
+ source: event_data[:source],
122
+ handled: event_data[:handled] || false,
123
+ occurrence_count: 1,
124
+ first_seen_at: Time.current,
125
+ last_seen_at: Time.current,
126
+ status: STATUS_UNRESOLVED
127
+ )
128
+ end
129
+ end
130
+ end
131
+
132
+ # Mark this error as resolved
133
+ def resolve!
134
+ update!(status: STATUS_RESOLVED)
135
+ end
136
+
137
+ # Mark this error as ignored
138
+ def ignore!
139
+ update!(status: STATUS_IGNORED)
140
+ end
141
+
142
+ # Reopen a resolved/ignored error
143
+ def reopen!
144
+ update!(status: STATUS_UNRESOLVED)
145
+ end
146
+
147
+ # Get parsed backtrace as array
148
+ def backtrace_lines
149
+ return [] unless backtrace
150
+
151
+ backtrace.is_a?(Array) ? backtrace : JSON.parse(backtrace)
152
+ rescue JSON::ParserError
153
+ backtrace.to_s.split("\n")
154
+ end
155
+
156
+ # Get user info from context
157
+ def user
158
+ context&.dig("user") || context&.dig(:user)
159
+ end
160
+
161
+ # Get request info from context
162
+ def request
163
+ context&.dig("request") || context&.dig(:request)
164
+ end
165
+
166
+ # Get breadcrumbs from context
167
+ def breadcrumbs
168
+ context&.dig("breadcrumbs") || context&.dig(:breadcrumbs) || []
169
+ end
170
+
171
+ # Get tags from context
172
+ def tags
173
+ context&.dig("tags") || context&.dig(:tags) || {}
174
+ end
175
+
176
+ # Short summary for lists
177
+ def summary
178
+ "#{exception_class}: #{message&.truncate(100)}"
179
+ end
180
+
181
+ private
182
+
183
+ def self.merge_contexts(old_context, new_context)
184
+ return new_context if old_context.blank?
185
+ return old_context if new_context.blank?
186
+
187
+ # Deep merge, preferring new values
188
+ old_context.deep_merge(new_context)
189
+ end
190
+
191
+ def self.serialize_backtrace(backtrace)
192
+ return nil unless backtrace
193
+
194
+ backtrace.is_a?(Array) ? backtrace.to_json : backtrace
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ # PerformanceEvent stores captured performance data in the database.
5
+ #
6
+ # DATABASE SCHEMA
7
+ # ===============
8
+ #
9
+ # create_table :findbug_performance_events do |t|
10
+ # t.string :transaction_name, null: false
11
+ # t.string :transaction_type, default: 'request'
12
+ # t.string :request_method
13
+ # t.string :request_path
14
+ # t.string :format
15
+ # t.integer :status
16
+ # t.float :duration_ms, null: false
17
+ # t.float :db_time_ms, default: 0
18
+ # t.float :view_time_ms, default: 0
19
+ # t.integer :query_count, default: 0
20
+ # t.jsonb :slow_queries, default: []
21
+ # t.jsonb :n_plus_one_queries, default: []
22
+ # t.boolean :has_n_plus_one, default: false
23
+ # t.integer :view_count, default: 0
24
+ # t.jsonb :context, default: {}
25
+ # t.string :environment
26
+ # t.string :release_version
27
+ # t.datetime :captured_at
28
+ # t.timestamps
29
+ # end
30
+ #
31
+ # AGGREGATION STRATEGY
32
+ # ====================
33
+ #
34
+ # Unlike errors (which we group by fingerprint), we store every
35
+ # performance event individually. This allows:
36
+ #
37
+ # - Percentile calculations (p50, p95, p99)
38
+ # - Trend analysis over time
39
+ # - Individual slow request investigation
40
+ #
41
+ # For dashboards, we aggregate on read using SQL GROUP BY.
42
+ #
43
+ class PerformanceEvent < ActiveRecord::Base
44
+ self.table_name = "findbug_performance_events"
45
+
46
+ # Transaction types
47
+ TYPE_REQUEST = "request"
48
+ TYPE_CUSTOM = "custom"
49
+ TYPE_JOB = "job"
50
+
51
+ # Validations
52
+ validates :transaction_name, presence: true
53
+ validates :duration_ms, presence: true, numericality: { greater_than_or_equal_to: 0 }
54
+
55
+ # Scopes
56
+ scope :requests, -> { where(transaction_type: TYPE_REQUEST) }
57
+ scope :custom, -> { where(transaction_type: TYPE_CUSTOM) }
58
+ scope :jobs, -> { where(transaction_type: TYPE_JOB) }
59
+ scope :slow, -> { where("duration_ms >= ?", Findbug.config.slow_request_threshold_ms) }
60
+ scope :with_n_plus_one, -> { where(has_n_plus_one: true) }
61
+ scope :recent, -> { order(captured_at: :desc) }
62
+
63
+ # Time-based scopes
64
+ scope :last_hour, -> { where("captured_at >= ?", 1.hour.ago) }
65
+ scope :last_24_hours, -> { where("captured_at >= ?", 24.hours.ago) }
66
+ scope :last_7_days, -> { where("captured_at >= ?", 7.days.ago) }
67
+
68
+ # Create a performance event from Redis data
69
+ #
70
+ # @param event_data [Hash] the performance event data
71
+ # @return [PerformanceEvent] the created event
72
+ #
73
+ def self.create_from_event(event_data)
74
+ create!(
75
+ transaction_name: event_data[:transaction_name],
76
+ transaction_type: event_data[:transaction_type] || TYPE_REQUEST,
77
+ request_method: event_data[:request_method],
78
+ request_path: event_data[:request_path],
79
+ format: event_data[:format],
80
+ status: event_data[:status],
81
+ duration_ms: event_data[:duration_ms],
82
+ db_time_ms: event_data[:db_time_ms] || 0,
83
+ view_time_ms: event_data[:view_time_ms] || 0,
84
+ query_count: event_data[:query_count] || 0,
85
+ slow_queries: event_data[:slow_queries] || [],
86
+ n_plus_one_queries: event_data[:n_plus_one_queries] || [],
87
+ has_n_plus_one: event_data[:has_n_plus_one] || false,
88
+ view_count: event_data[:view_count] || 0,
89
+ context: event_data[:context] || {},
90
+ environment: event_data[:environment],
91
+ release_version: event_data[:release],
92
+ captured_at: parse_captured_at(event_data[:captured_at])
93
+ )
94
+ end
95
+
96
+ # Aggregate stats for a transaction
97
+ #
98
+ # @param transaction_name [String] the transaction to aggregate
99
+ # @param since [Time] start time for aggregation
100
+ # @return [Hash] aggregated statistics
101
+ #
102
+ def self.aggregate_for(transaction_name, since: 24.hours.ago)
103
+ events = where(transaction_name: transaction_name)
104
+ .where("captured_at >= ?", since)
105
+
106
+ return nil if events.empty?
107
+
108
+ durations = events.pluck(:duration_ms).sort
109
+
110
+ {
111
+ transaction_name: transaction_name,
112
+ count: events.count,
113
+ avg_duration_ms: durations.sum / durations.size.to_f,
114
+ min_duration_ms: durations.first,
115
+ max_duration_ms: durations.last,
116
+ p50_duration_ms: percentile(durations, 50),
117
+ p95_duration_ms: percentile(durations, 95),
118
+ p99_duration_ms: percentile(durations, 99),
119
+ avg_query_count: events.average(:query_count).to_f.round(1),
120
+ n_plus_one_count: events.where(has_n_plus_one: true).count
121
+ }
122
+ end
123
+
124
+ # Get slowest transactions
125
+ #
126
+ # @param since [Time] start time
127
+ # @param limit [Integer] max results
128
+ # @return [Array<Hash>] slowest transactions with stats
129
+ #
130
+ def self.slowest_transactions(since: 24.hours.ago, limit: 10)
131
+ where("captured_at >= ?", since)
132
+ .group(:transaction_name)
133
+ .select(
134
+ "transaction_name",
135
+ "AVG(duration_ms) as avg_duration",
136
+ "MAX(duration_ms) as max_duration",
137
+ "COUNT(*) as request_count"
138
+ )
139
+ .order("avg_duration DESC")
140
+ .limit(limit)
141
+ .map do |row|
142
+ {
143
+ transaction_name: row.transaction_name,
144
+ avg_duration_ms: row.avg_duration.round(2),
145
+ max_duration_ms: row.max_duration.round(2),
146
+ count: row.request_count
147
+ }
148
+ end
149
+ end
150
+
151
+ # Get transactions with most N+1 issues
152
+ #
153
+ # @param since [Time] start time
154
+ # @param limit [Integer] max results
155
+ # @return [Array<Hash>] transactions with N+1 stats
156
+ #
157
+ def self.n_plus_one_hotspots(since: 24.hours.ago, limit: 10)
158
+ with_n_plus_one
159
+ .where("captured_at >= ?", since)
160
+ .group(:transaction_name)
161
+ .select(
162
+ "transaction_name",
163
+ "COUNT(*) as occurrence_count",
164
+ "AVG(query_count) as avg_queries"
165
+ )
166
+ .order("occurrence_count DESC")
167
+ .limit(limit)
168
+ .map do |row|
169
+ {
170
+ transaction_name: row.transaction_name,
171
+ n_plus_one_count: row.occurrence_count,
172
+ avg_queries: row.avg_queries.round(1)
173
+ }
174
+ end
175
+ end
176
+
177
+ # Get throughput over time (requests per minute)
178
+ #
179
+ # @param since [Time] start time
180
+ # @param interval [String] grouping interval ('minute', 'hour', 'day')
181
+ # @return [Array<Hash>] time series data
182
+ #
183
+ def self.throughput_over_time(since: 24.hours.ago, interval: "hour")
184
+ # This uses database-specific date truncation
185
+ # Works with PostgreSQL; adjust for other databases
186
+ time_column = case interval
187
+ when "minute" then "date_trunc('minute', captured_at)"
188
+ when "hour" then "date_trunc('hour', captured_at)"
189
+ when "day" then "date_trunc('day', captured_at)"
190
+ else "date_trunc('hour', captured_at)"
191
+ end
192
+
193
+ where("captured_at >= ?", since)
194
+ .group(Arel.sql(time_column))
195
+ .select(
196
+ Arel.sql("#{time_column} as time_bucket"),
197
+ "COUNT(*) as request_count",
198
+ "AVG(duration_ms) as avg_duration"
199
+ )
200
+ .order(Arel.sql(time_column))
201
+ .map do |row|
202
+ {
203
+ time: row.time_bucket,
204
+ count: row.request_count,
205
+ avg_duration_ms: row.avg_duration.round(2)
206
+ }
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ def self.percentile(sorted_array, percentile)
213
+ return 0 if sorted_array.empty?
214
+
215
+ k = (percentile / 100.0) * (sorted_array.length - 1)
216
+ f = k.floor
217
+ c = k.ceil
218
+
219
+ if f == c
220
+ sorted_array[f]
221
+ else
222
+ sorted_array[f] + (k - f) * (sorted_array[c] - sorted_array[f])
223
+ end
224
+ end
225
+
226
+ def self.parse_captured_at(value)
227
+ case value
228
+ when Time, DateTime
229
+ value
230
+ when String
231
+ Time.parse(value)
232
+ else
233
+ Time.current
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,199 @@
1
+ <h1>Dashboard</h1>
2
+ <p class="page-description">Monitor errors and performance across your application.</p>
3
+
4
+ <%# Redis connection warning %>
5
+ <% if @stats[:buffer][:circuit_breaker_state] != :closed || @stats[:buffer][:error].present? %>
6
+ <div class="flash flash-error" style="margin-bottom: 1.5rem;">
7
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zM5.22 5.22a.75.75 0 0 1 1.06 0L8 6.94l1.72-1.72a.75.75 0 1 1 1.06 1.06L9.06 8l1.72 1.72a.75.75 0 1 1-1.06 1.06L8 9.06l-1.72 1.72a.75.75 0 0 1-1.06-1.06L6.94 8 5.22 6.28a.75.75 0 0 1 0-1.06z"/></svg>
8
+ <div>
9
+ <strong>Redis connection failed.</strong> Errors are not being captured.
10
+ <% if @stats[:buffer][:error].present? %>
11
+ <br><span class="text-muted text-xs"><%= @stats[:buffer][:error] %></span>
12
+ <% end %>
13
+ <br><span class="text-muted text-xs">Configured URL: <code style="background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem;"><%= Findbug.config.redis_url %></code></span>
14
+ </div>
15
+ </div>
16
+ <% end %>
17
+
18
+ <%# Stats overview %>
19
+ <div class="stats-grid">
20
+ <div class="stat-card">
21
+ <div class="stat-label">Unresolved Errors</div>
22
+ <div class="stat-value <%= @stats[:errors][:unresolved] > 0 ? 'error' : '' %>">
23
+ <%= @stats[:errors][:unresolved] %>
24
+ </div>
25
+ <div class="stat-change">Total: <%= @stats[:errors][:total] %></div>
26
+ </div>
27
+
28
+ <div class="stat-card">
29
+ <div class="stat-label">Errors (24h)</div>
30
+ <div class="stat-value"><%= @stats[:errors][:last_24h] %></div>
31
+ <div class="stat-change">Last 7d: <%= @stats[:errors][:last_7d] %></div>
32
+ </div>
33
+
34
+ <div class="stat-card">
35
+ <div class="stat-label">Avg Response Time</div>
36
+ <div class="stat-value"><%= @stats[:performance][:avg_duration] %><span class="text-muted text-sm">ms</span></div>
37
+ <div class="stat-change"><%= @stats[:performance][:last_24h] %> requests (24h)</div>
38
+ </div>
39
+
40
+ <div class="stat-card">
41
+ <div class="stat-label">N+1 Issues (24h)</div>
42
+ <div class="stat-value <%= @stats[:performance][:n_plus_one_count] > 0 ? 'warning' : '' %>">
43
+ <%= @stats[:performance][:n_plus_one_count] %>
44
+ </div>
45
+ <div class="stat-change">Performance issues detected</div>
46
+ </div>
47
+ </div>
48
+
49
+ <div class="grid-2">
50
+ <%# Recent Errors %>
51
+ <div class="card">
52
+ <div class="card-header">
53
+ <div>
54
+ <h2 class="card-title">Recent Errors</h2>
55
+ <p class="card-description">Unresolved errors from your application</p>
56
+ </div>
57
+ <a href="<%= findbug.errors_path %>" class="btn btn-outline btn-sm">View All</a>
58
+ </div>
59
+
60
+ <% if @recent_errors.any? %>
61
+ <table class="table">
62
+ <thead>
63
+ <tr>
64
+ <th>Error</th>
65
+ <th>Count</th>
66
+ <th>Last Seen</th>
67
+ </tr>
68
+ </thead>
69
+ <tbody>
70
+ <% @recent_errors.each do |error| %>
71
+ <tr>
72
+ <td>
73
+ <a href="<%= findbug.error_path(error) %>">
74
+ <strong class="font-mono"><%= error.exception_class %></strong>
75
+ </a>
76
+ <br>
77
+ <span class="text-muted text-sm"><%= truncate(error.message, length: 60) %></span>
78
+ </td>
79
+ <td>
80
+ <span class="badge badge-<%= error.occurrence_count > 10 ? 'error' : 'muted' %>">
81
+ <%= error.occurrence_count %>
82
+ </span>
83
+ </td>
84
+ <td class="text-muted text-sm">
85
+ <%= time_ago_in_words(error.last_seen_at) %> ago
86
+ </td>
87
+ </tr>
88
+ <% end %>
89
+ </tbody>
90
+ </table>
91
+ <% else %>
92
+ <div class="empty-state">
93
+ <div class="empty-state-icon">
94
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
95
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
96
+ <polyline points="22 4 12 14.01 9 11.01"/>
97
+ </svg>
98
+ </div>
99
+ <p>No unresolved errors</p>
100
+ </div>
101
+ <% end %>
102
+ </div>
103
+
104
+ <%# Slowest Endpoints %>
105
+ <div class="card">
106
+ <div class="card-header">
107
+ <div>
108
+ <h2 class="card-title">Slowest Endpoints</h2>
109
+ <p class="card-description">Performance data from the last 24 hours</p>
110
+ </div>
111
+ <a href="<%= findbug.performance_index_path %>" class="btn btn-outline btn-sm">View All</a>
112
+ </div>
113
+
114
+ <% if @slowest_endpoints.any? %>
115
+ <table class="table">
116
+ <thead>
117
+ <tr>
118
+ <th>Endpoint</th>
119
+ <th>Avg</th>
120
+ <th>Max</th>
121
+ </tr>
122
+ </thead>
123
+ <tbody>
124
+ <% @slowest_endpoints.each do |endpoint| %>
125
+ <tr>
126
+ <td>
127
+ <a href="<%= findbug.performance_path(endpoint[:transaction_name]) %>" class="font-mono text-sm">
128
+ <%= truncate(endpoint[:transaction_name], length: 35) %>
129
+ </a>
130
+ </td>
131
+ <td>
132
+ <span class="badge badge-<%= endpoint[:avg_duration_ms] > 500 ? 'warning' : 'muted' %>">
133
+ <%= endpoint[:avg_duration_ms].round %>ms
134
+ </span>
135
+ </td>
136
+ <td class="text-muted text-sm">
137
+ <%= endpoint[:max_duration_ms].round %>ms
138
+ </td>
139
+ </tr>
140
+ <% end %>
141
+ </tbody>
142
+ </table>
143
+ <% else %>
144
+ <div class="empty-state">
145
+ <div class="empty-state-icon">
146
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
147
+ <line x1="12" y1="20" x2="12" y2="10"/>
148
+ <line x1="18" y1="20" x2="18" y2="4"/>
149
+ <line x1="6" y1="20" x2="6" y2="16"/>
150
+ </svg>
151
+ </div>
152
+ <p>No performance data yet</p>
153
+ </div>
154
+ <% end %>
155
+ </div>
156
+ </div>
157
+
158
+ <%# System Status %>
159
+ <div class="card">
160
+ <div class="card-header">
161
+ <div>
162
+ <h2 class="card-title">System Status</h2>
163
+ <p class="card-description">Buffer and system health information</p>
164
+ </div>
165
+ </div>
166
+ <div class="card-content">
167
+ <div class="stats-grid">
168
+ <div>
169
+ <div class="stat-label">Error Buffer</div>
170
+ <div class="stat-value" style="font-size: 1.5rem;">
171
+ <%= @stats[:buffer][:error_queue_length] || 0 %>
172
+ </div>
173
+ <div class="stat-change">events pending</div>
174
+ </div>
175
+ <div>
176
+ <div class="stat-label">Perf Buffer</div>
177
+ <div class="stat-value" style="font-size: 1.5rem;">
178
+ <%= @stats[:buffer][:performance_queue_length] || 0 %>
179
+ </div>
180
+ <div class="stat-change">events pending</div>
181
+ </div>
182
+ <div>
183
+ <div class="stat-label">Circuit Breaker</div>
184
+ <div style="margin-top: 0.5rem;">
185
+ <span class="status-dot <%= @stats[:buffer][:circuit_breaker_state] == :closed ? 'success' : 'error' %>"></span>
186
+ <span class="text-sm"><%= @stats[:buffer][:circuit_breaker_state] %></span>
187
+ </div>
188
+ <div class="stat-change">Redis connection status</div>
189
+ </div>
190
+ <div>
191
+ <div class="stat-label">Total Events</div>
192
+ <div class="stat-value" style="font-size: 1.5rem;">
193
+ <%= @stats[:errors][:total] + @stats[:performance][:total] %>
194
+ </div>
195
+ <div class="stat-change">all time</div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>